feat: segregate super admin and LGU admin workflows
- Created AdminBarangayController to strictly scope barangay management to LGU boundaries - Created AdminTenantController for LGU admins to fetch their own tenant configurations - Updated sidebar to expose Barangays to LGU admins under the Areas menu - Updated frontend UI to hide the City selection dropdown from LGU admins and dynamically inject their city_municipality_id - Handled form validation issues caused by hidden required fields - Implemented LGU dashboard routes and views - Enforced 2 free QR code allocations on resident signup
This commit is contained in:
236
app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php
Normal file
236
app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Http\Requests\SuperAdmin\StoreBarangayRequest;
|
||||
use App\Http\Requests\SuperAdmin\UpdateBarangayRequest;
|
||||
use App\Http\Resources\BarangayResource;
|
||||
use App\Models\Barangay;
|
||||
use App\Models\CityMunicipality;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use MatanYadaev\EloquentSpatial\Objects\LineString;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Polygon;
|
||||
|
||||
class AdminBarangayController extends ApiController
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$tenant = $request->user()->tenant;
|
||||
$cityId = $tenant->city_municipality_id;
|
||||
|
||||
$perPage = (int) $request->input('per_page', 25);
|
||||
|
||||
$barangays = Barangay::query()
|
||||
->with(['cityMunicipality.province'])
|
||||
->where('city_municipality_id', $cityId)
|
||||
->when($request->filled('q'), function ($q) use ($request) {
|
||||
$term = '%'.$request->string('q').'%';
|
||||
$q->where(function ($qq) use ($term) {
|
||||
$qq->where('name', 'like', $term)
|
||||
->orWhere('code', 'like', $term)
|
||||
->orWhere('psgc_code', 'like', $term);
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
|
||||
return $this->ok(BarangayResource::collection($barangays));
|
||||
}
|
||||
|
||||
public function store(StoreBarangayRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$cityId = $tenant->city_municipality_id;
|
||||
|
||||
$boundary = $this->buildPolygon($data['boundary']);
|
||||
$centroid = $this->calculateCentroid($boundary);
|
||||
|
||||
if ($tenant->boundary_polygon) {
|
||||
$isContained = DB::selectOne(
|
||||
'SELECT ST_Contains(boundary_polygon, ST_GeomFromText(?, 4326, \'axis-order=long-lat\')) as contained FROM tenants WHERE id = ?',
|
||||
[$boundary->toWkt(), $tenant->id]
|
||||
);
|
||||
|
||||
if (! $isContained || ! $isContained->contained) {
|
||||
return $this->fail(
|
||||
'The drawn Barangay boundary must lie completely within your LGU boundary.',
|
||||
['boundary' => ['The Barangay boundary is outside your LGU border.']],
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Code auto-generation if empty
|
||||
$city = CityMunicipality::findOrFail($cityId);
|
||||
if (empty($data['psgc_code'])) {
|
||||
$prefix = substr($city->psgc_code, 0, 9);
|
||||
$maxLocal = Barangay::where('psgc_code', 'like', $prefix.'%')
|
||||
->whereRaw('LENGTH(psgc_code) = 12')
|
||||
->orderBy('psgc_code', 'desc')
|
||||
->first();
|
||||
$suffix = $maxLocal ? ((int) substr($maxLocal->psgc_code, -3) + 1) : 1;
|
||||
$data['psgc_code'] = $prefix.str_pad($suffix, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
if (empty($data['code'])) {
|
||||
$prefix = substr($city->code, 0, 12);
|
||||
$maxLocal = Barangay::where('code', 'like', $prefix.'%')
|
||||
->orderBy('code', 'desc')
|
||||
->first();
|
||||
$suffix = $maxLocal ? ((int) substr(strrchr($maxLocal->code, '-'), 1) + 1) : 1;
|
||||
$data['code'] = $prefix.'-'.str_pad($suffix, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$barangay = Barangay::create([
|
||||
'name' => $data['name'],
|
||||
'city_municipality_id' => $cityId,
|
||||
'psgc_code' => $data['psgc_code'],
|
||||
'code' => $data['code'],
|
||||
'urban_rural' => $data['urban_rural'],
|
||||
'population' => $data['population'],
|
||||
'boundary' => $boundary,
|
||||
'centroid' => $centroid,
|
||||
]);
|
||||
|
||||
return $this->created(new BarangayResource($barangay), 'Barangay created successfully');
|
||||
}
|
||||
|
||||
public function show(Request $request, Barangay $barangay): JsonResponse
|
||||
{
|
||||
$tenant = $request->user()->tenant;
|
||||
if ($barangay->city_municipality_id !== $tenant->city_municipality_id) {
|
||||
abort(403, 'Unauthorized to view this barangay.');
|
||||
}
|
||||
|
||||
$barangay->load(['cityMunicipality.province']);
|
||||
|
||||
return $this->ok(new BarangayResource($barangay));
|
||||
}
|
||||
|
||||
public function update(UpdateBarangayRequest $request, Barangay $barangay): JsonResponse
|
||||
{
|
||||
$tenant = $request->user()->tenant;
|
||||
if ($barangay->city_municipality_id !== $tenant->city_municipality_id) {
|
||||
abort(403, 'Unauthorized to modify this barangay.');
|
||||
}
|
||||
|
||||
$data = $request->validated();
|
||||
$cityId = $tenant->city_municipality_id;
|
||||
|
||||
$boundary = $this->buildPolygon($data['boundary']);
|
||||
$centroid = $this->calculateCentroid($boundary);
|
||||
|
||||
if ($tenant->boundary_polygon) {
|
||||
$isContained = DB::selectOne(
|
||||
'SELECT ST_Contains(boundary_polygon, ST_GeomFromText(?, 4326, \'axis-order=long-lat\')) as contained FROM tenants WHERE id = ?',
|
||||
[$boundary->toWkt(), $tenant->id]
|
||||
);
|
||||
|
||||
if (! $isContained || ! $isContained->contained) {
|
||||
return $this->fail(
|
||||
'The drawn Barangay boundary must lie completely within your LGU boundary.',
|
||||
['boundary' => ['The Barangay boundary is outside your LGU border.']],
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Code auto-generation if empty
|
||||
$city = CityMunicipality::findOrFail($cityId);
|
||||
if (empty($data['psgc_code'])) {
|
||||
$prefix = substr($city->psgc_code, 0, 9);
|
||||
$maxLocal = Barangay::where('psgc_code', 'like', $prefix.'%')
|
||||
->whereRaw('LENGTH(psgc_code) = 12')
|
||||
->orderBy('psgc_code', 'desc')
|
||||
->first();
|
||||
$suffix = $maxLocal ? ((int) substr($maxLocal->psgc_code, -3) + 1) : 1;
|
||||
$data['psgc_code'] = $prefix.str_pad($suffix, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
if (empty($data['code'])) {
|
||||
$prefix = substr($city->code, 0, 12);
|
||||
$maxLocal = Barangay::where('code', 'like', $prefix.'%')
|
||||
->orderBy('code', 'desc')
|
||||
->first();
|
||||
$suffix = $maxLocal ? ((int) substr(strrchr($maxLocal->code, '-'), 1) + 1) : 1;
|
||||
$data['code'] = $prefix.'-'.str_pad($suffix, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$barangay->update([
|
||||
'name' => $data['name'],
|
||||
'psgc_code' => $data['psgc_code'],
|
||||
'code' => $data['code'],
|
||||
'urban_rural' => $data['urban_rural'],
|
||||
'population' => $data['population'],
|
||||
'boundary' => $boundary,
|
||||
'centroid' => $centroid,
|
||||
]);
|
||||
|
||||
return $this->ok(new BarangayResource($barangay), 'Barangay updated successfully');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Barangay $barangay): JsonResponse
|
||||
{
|
||||
$tenant = $request->user()->tenant;
|
||||
if ($barangay->city_municipality_id !== $tenant->city_municipality_id) {
|
||||
abort(403, 'Unauthorized to delete this barangay.');
|
||||
}
|
||||
|
||||
$barangay->delete();
|
||||
|
||||
return $this->ok(null, 'Barangay deleted successfully');
|
||||
}
|
||||
|
||||
private function buildPolygon(array $points): Polygon
|
||||
{
|
||||
$ring = array_map(
|
||||
fn ($p) => new Point((float) $p['lat'], (float) $p['lng'], 4326),
|
||||
$points,
|
||||
);
|
||||
|
||||
$first = $ring[0];
|
||||
$last = $ring[count($ring) - 1];
|
||||
if ($first->latitude !== $last->latitude || $first->longitude !== $last->longitude) {
|
||||
$ring[] = new Point($first->latitude, $first->longitude, 4326);
|
||||
}
|
||||
|
||||
return new Polygon([new LineString($ring)], 4326);
|
||||
}
|
||||
|
||||
private function calculateCentroid(Polygon $polygon): Point
|
||||
{
|
||||
$res = DB::selectOne(
|
||||
'SELECT ST_AsText(ST_SRID(ST_Centroid(ST_GeomFromText(?, 0)), 4326)) as wkt',
|
||||
[$polygon->toWkt()]
|
||||
);
|
||||
|
||||
if ($res && preg_match('/POINT\(([^ ]+) ([^ ]+)\)/', $res->wkt, $matches)) {
|
||||
$lat = (float) $matches[1];
|
||||
$lng = (float) $matches[2];
|
||||
|
||||
return new Point($lat, $lng, 4326);
|
||||
}
|
||||
|
||||
$rings = $polygon->getGeometries();
|
||||
$ring = $rings->first();
|
||||
if ($ring) {
|
||||
$coords = $ring->getGeometries();
|
||||
$lats = $coords->map(fn ($p) => $p->latitude)->all();
|
||||
$lngs = $coords->map(fn ($p) => $p->longitude)->all();
|
||||
|
||||
return new Point(array_sum($lats) / count($lats), array_sum($lngs) / count($lngs), 4326);
|
||||
}
|
||||
|
||||
return new Point(0, 0, 4326);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,25 @@ class AdminQrBatchController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
public function transfer(Request $request, QrCodeBatch $qrCodeBatch): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'target_store_id' => ['nullable', 'integer', 'exists:partner_stores,id'],
|
||||
'target_area_id' => ['nullable', 'integer', 'exists:service_areas,id'],
|
||||
]);
|
||||
|
||||
if (!$request->target_store_id && !$request->target_area_id) {
|
||||
return $this->fail('Must provide either target_store_id or target_area_id', null, 422);
|
||||
}
|
||||
|
||||
$qrCodeBatch->update($request->only('target_store_id', 'target_area_id'));
|
||||
|
||||
return $this->ok(
|
||||
new QrCodeBatchResource($qrCodeBatch->fresh()->load(['targetArea', 'createdBy', 'codes'])),
|
||||
'Batch transferred successfully',
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(QrCodeBatch $qrCodeBatch): JsonResponse
|
||||
{
|
||||
// Delete any unused/unassigned codes to free up space
|
||||
|
||||
32
app/Http/Controllers/Api/V1/Admin/AdminTenantController.php
Normal file
32
app/Http/Controllers/Api/V1/Admin/AdminTenantController.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminTenantController extends ApiController
|
||||
{
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->user()->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
return $this->fail('Tenant not found', [], 404);
|
||||
}
|
||||
|
||||
// We can just return the tenant data
|
||||
return $this->ok([
|
||||
'id' => $tenant->id,
|
||||
'uuid' => $tenant->uuid,
|
||||
'name' => $tenant->name,
|
||||
'code' => $tenant->code,
|
||||
'city_municipality_id' => $tenant->city_municipality_id,
|
||||
'boundary_polygon' => $tenant->boundary_polygon ? $tenant->boundary_polygon->toArray() : null,
|
||||
'logo_path' => $tenant->logo_path,
|
||||
'contact_email' => $tenant->contact_email,
|
||||
'contact_phone' => $tenant->contact_phone,
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/Api/V1/Admin/DashboardController.php
Normal file
57
app/Http/Controllers/Api/V1/Admin/DashboardController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Models\Household;
|
||||
use App\Models\PartnerStore;
|
||||
use App\Models\QrCodeBatch;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends ApiController
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$pendingHouseholdsCount = Household::where('tenant_id', $tenantId)
|
||||
->where('verification_status', 'pending')
|
||||
->count();
|
||||
|
||||
$activeHouseholdsCount = Household::where('tenant_id', $tenantId)
|
||||
->where('verification_status', 'approved')
|
||||
->count();
|
||||
|
||||
$pendingStoresCount = PartnerStore::where('tenant_id', $tenantId)
|
||||
->where('status', 'pending_kyc')
|
||||
->count();
|
||||
|
||||
$totalQrBatches = QrCodeBatch::where('tenant_id', $tenantId)->count();
|
||||
|
||||
$recentPendingHouseholds = Household::where('tenant_id', $tenantId)
|
||||
->where('verification_status', 'pending')
|
||||
->with(['head', 'barangay'])
|
||||
->latest()
|
||||
->take(5)
|
||||
->get()
|
||||
->map(function ($h) {
|
||||
return [
|
||||
'id' => $h->uuid,
|
||||
'full_name' => $h->head ? $h->head->full_name : '—',
|
||||
'address_line' => $h->address_line,
|
||||
'has_proof' => (bool)$h->proof_of_residency_uploaded,
|
||||
];
|
||||
});
|
||||
|
||||
return $this->ok([
|
||||
'metrics' => [
|
||||
'pending_households' => $pendingHouseholdsCount,
|
||||
'active_households' => $activeHouseholdsCount,
|
||||
'pending_stores' => $pendingStoresCount,
|
||||
'total_qr_batches' => $totalQrBatches,
|
||||
],
|
||||
'recent_pending_households' => $recentPendingHouseholds,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ class RegisterController extends ApiController
|
||||
'relationship' => HouseholdMember::RELATIONSHIP_HEAD,
|
||||
'full_name' => $user->full_name,
|
||||
]);
|
||||
|
||||
app(\App\Services\Qr\QrAllocator::class)->allocateFreeToHousehold($household);
|
||||
}
|
||||
|
||||
return $user;
|
||||
|
||||
@@ -131,6 +131,39 @@ class SuperAdminTenantController extends ApiController
|
||||
'status' => $data['status'] ?? $tenant->status,
|
||||
'timezone' => $data['timezone'] ?? $tenant->timezone,
|
||||
]);
|
||||
|
||||
$admin = $tenant->users()->where('role', User::ROLE_ADMIN)->first();
|
||||
if (!$admin && !empty($data['admin_email']) && !empty($data['admin_password'])) {
|
||||
$admin = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'first_name' => 'LGU',
|
||||
'last_name' => 'Admin',
|
||||
'email' => $data['admin_email'],
|
||||
'phone' => $data['contact_phone'],
|
||||
'password' => Hash::make($data['admin_password']),
|
||||
'role' => User::ROLE_ADMIN,
|
||||
'status' => User::STATUS_ACTIVE,
|
||||
]);
|
||||
$admin->assignRole(User::ROLE_ADMIN);
|
||||
\App\Models\NotificationPreference::create([
|
||||
'user_id' => $admin->id,
|
||||
'language' => 'en',
|
||||
]);
|
||||
} elseif ($admin) {
|
||||
if (!empty($data['admin_email'])) {
|
||||
// Ignore unique constraint on the same user
|
||||
$existing = User::where('email', $data['admin_email'])->where('id', '!=', $admin->id)->first();
|
||||
if (!$existing) {
|
||||
$admin->email = $data['admin_email'];
|
||||
}
|
||||
}
|
||||
if (!empty($data['admin_password'])) {
|
||||
$admin->password = Hash::make($data['admin_password']);
|
||||
}
|
||||
if ($admin->isDirty('email') || $admin->isDirty('password')) {
|
||||
$admin->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$tenant->load(['cityMunicipality.province.region', 'users']);
|
||||
|
||||
@@ -16,6 +16,9 @@ class StoreTenantRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
$isCreate = $this->isMethod('post');
|
||||
$tenant = $this->route('tenant');
|
||||
$admin = $tenant ? $tenant->users()->where('role', \App\Models\User::ROLE_ADMIN)->first() : null;
|
||||
$adminEmailRule = $isCreate ? 'unique:users,email' : Rule::unique('users', 'email')->ignore($admin?->id);
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:191'],
|
||||
@@ -31,9 +34,9 @@ class StoreTenantRequest extends FormRequest
|
||||
'status' => ['nullable', Rule::in([Tenant::STATUS_ONBOARDING, Tenant::STATUS_ACTIVE, Tenant::STATUS_SUSPENDED])],
|
||||
'timezone' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
// Default Admin Account Info (Only required during creation)
|
||||
'admin_email' => $isCreate ? ['required', 'email', 'max:191', 'unique:users,email'] : ['nullable'],
|
||||
'admin_password' => $isCreate ? ['required', 'string', 'min:8'] : ['nullable'],
|
||||
// Default Admin Account Info
|
||||
'admin_email' => $isCreate ? ['required', 'email', 'max:191', 'unique:users,email'] : ['nullable', 'email', 'max:191', $adminEmailRule],
|
||||
'admin_password' => $isCreate ? ['required', 'string', 'min:8'] : ['nullable', 'string', 'min:8'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class QrAllocator
|
||||
*/
|
||||
public function allocateFreeToHousehold(Household $household): int
|
||||
{
|
||||
$quota = (int) config('qr.free_allocation_per_household', 10);
|
||||
$quota = (int) config('qr.free_allocation_per_household', 2);
|
||||
|
||||
return DB::transaction(function () use ($household, $quota) {
|
||||
// Idempotency: if any QR codes have ever been assigned to this
|
||||
|
||||
@@ -4,7 +4,7 @@ return [
|
||||
/*
|
||||
| How many free QR codes a verified household receives.
|
||||
*/
|
||||
'free_allocation_per_household' => (int) env('QR_FREE_ALLOCATION_PER_HOUSEHOLD', 10),
|
||||
'free_allocation_per_household' => (int) env('QR_FREE_ALLOCATION_PER_HOUSEHOLD', 2),
|
||||
|
||||
/*
|
||||
| When a household's active code count drops to or below this number,
|
||||
|
||||
69
lgu-dashboard-qr.md
Normal file
69
lgu-dashboard-qr.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# LGU Dashboard & QR Ecosystem Implementation Plan
|
||||
|
||||
## Overview
|
||||
Implement the Local Government Unit (LGU) "Operational Command" dashboard (Option B) to allow LGU admins to manage Barangays, Service Areas, Drop-off Points, Dumpsites, and Households. Incorporate a new QR code distribution workflow for LGUs to wholesale QR batches to Partner Stores/Barangays, and automatically grant 3 free QR codes to new users upon verified registration.
|
||||
|
||||
## Project Type
|
||||
**WEB + BACKEND**
|
||||
|
||||
## Success Criteria
|
||||
1. LGU Admin users have a specialized "Operational Command" dashboard.
|
||||
2. The dashboard surfaces actionable metrics (pending approvals, low inventory) and provides quick links to infrastructure management (Brgys, Service Areas, DOPs, Dumpsites, Households).
|
||||
3. LGU Admins can distribute existing QR Batches to specific Partner Stores or Barangays (Wholesale Transfer).
|
||||
4. Newly verified households automatically receive 3 free QR codes from the LGU's designated free subsidy batch.
|
||||
5. UI follows `frontend-design` and `web-design-guidelines` (clean layout, 8-point grid, appropriate typography scale, actionable UX).
|
||||
|
||||
## Tech Stack
|
||||
- **Frontend**: Blade components, TailwindCSS 3.4 (Alpine.js if interactivity needed).
|
||||
- **Backend**: Laravel 11, Spatie Model States (for QR lifecycle).
|
||||
|
||||
## File Structure
|
||||
|
||||
### Proposed Additions & Modifications
|
||||
```
|
||||
Web/
|
||||
├── app/
|
||||
│ ├── Listeners/
|
||||
│ │ └── AllocateFreeQrCodesOnHouseholdVerified.php [MODIFY] (Adjust to grant 3 free codes)
|
||||
│ ├── Services/
|
||||
│ │ ├── Qr/
|
||||
│ │ │ └── QrAllocator.php [MODIFY] (Refactor allocation logic to limit to 3)
|
||||
│ │ └── Store/
|
||||
│ │ └── StoreOperations.php [MODIFY] (Handle LGU to Store batch transfers)
|
||||
│ ├── Http/Controllers/
|
||||
│ │ └── Web/Admin/
|
||||
│ │ └── LguDashboardController.php [NEW] (Serve LGU specific dashboard data)
|
||||
├── routes/
|
||||
│ └── web.php [MODIFY] (Add LGU dashboard route)
|
||||
├── resources/views/
|
||||
│ ├── admin/
|
||||
│ │ ├── lgu-dashboard.blade.php [NEW] (The Option B Operational Command view)
|
||||
│ │ └── partials/
|
||||
│ │ └── lgu-sidebar.blade.php [NEW] (Role-specific sidebar)
|
||||
```
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Phase 1: Backend QR Allocation (3 Free Codes)
|
||||
| Task ID | Name | Agent | Skills | Priority | Description | INPUT → OUTPUT → VERIFY |
|
||||
|---|---|---|---|---|---|---|
|
||||
| B1 | Adjust Free Allocation | `backend-specialist` | `clean-code` | P1 | Modify `config/qr.php` to set `free_allocation_per_household = 3`. Update `QrAllocator` and `AllocateFreeQrCodesOnHouseholdVerified` to distribute exactly 3 codes to the user upon approval. | Input: config/qr.php → Output: Allocation logic fixed → Verify: Run tests or register a household and verify 3 codes assigned. |
|
||||
|
||||
### Phase 2: Wholesale QR Distribution Logic
|
||||
| Task ID | Name | Agent | Skills | Priority | Description | INPUT → OUTPUT → VERIFY |
|
||||
|---|---|---|---|---|---|---|
|
||||
| B2 | LGU Batch Transfer | `backend-specialist` | `api-patterns` | P1 | Implement an endpoint/method for LGU admins to assign an `unassigned` or `allocated` batch directly to a `target_store_id` (Partner Store) or Barangay. | Input: API Request → Output: Batch updated, codes transferred → Verify: DB shows correct target. |
|
||||
|
||||
### Phase 3: LGU Dashboard UI/UX (Option B)
|
||||
| Task ID | Name | Agent | Skills | Priority | Description | INPUT → OUTPUT → VERIFY |
|
||||
|---|---|---|---|---|---|---|
|
||||
| F1 | LGU Dashboard Controller | `backend-specialist` | `clean-code` | P1 | Create `LguDashboardController` to aggregate metrics: pending households, pending partner stores, total QR codes distributed, and low-inventory alerts. | Input: Eloquent queries → Output: Data passed to view → Verify: Correct counts match DB. |
|
||||
| F2 | LGU Dashboard View | `frontend-specialist` | `frontend-design` | P2 | Build `lgu-dashboard.blade.php`. Use an 8-point grid, clean typography, and a card-based layout for Pending Actions, Metrics, and Quick Links. | Input: Blade template → Output: Rendered HTML/Tailwind → Verify: Visual check, responsive on mobile. |
|
||||
| F3 | LGU Sidebar & Routing | `frontend-specialist` | `web-design-guidelines` | P2 | Update routing so LGU Admins land on this new dashboard. Build role-specific sidebar links for Infrastructure management. | Input: `web.php`, Blade partial → Output: Accessible navigation → Verify: Clicking links routes correctly. |
|
||||
|
||||
## Phase X: Verification
|
||||
- [ ] **Lint**: Run Pint/PHP_CodeSniffer and JS Linters.
|
||||
- [ ] **Security**: Verify LGU Admins cannot allocate codes from outside their jurisdiction.
|
||||
- [ ] **Build**: Vite build completes without warnings.
|
||||
- [ ] **Design Review**: Ensure no excessive glassmorphism, proper contrast (WCAG AA), and clear UX paths based on `frontend-design` guidelines.
|
||||
- [ ] **Test**: Register a new household and verify exactly 3 free QR codes are dispensed upon approval.
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "verde-web",
|
||||
"name": "Web",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</header>
|
||||
|
||||
<div class="card mb-4 flex flex-wrap items-center gap-3 p-4">
|
||||
<select id="filter-city" class="form-select w-64">
|
||||
<select id="filter-city" class="form-input text-xs w-48">
|
||||
<option value="">All Cities / Municipalities</option>
|
||||
</select>
|
||||
<input id="filter-q" type="search" placeholder="Search name or code…" class="form-input flex-1 min-w-[200px]">
|
||||
@@ -134,9 +134,25 @@
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Role check and page display guard
|
||||
const user = window.Verde?.getUser();
|
||||
const isSuperAdmin = user && user.role === 'super_admin';
|
||||
|
||||
// Always show the page now, but adjust UI based on role
|
||||
document.getElementById('page-content')?.style.setProperty('display', 'block');
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
// Hide city filters for LGU Admins
|
||||
document.getElementById('filter-city').classList.add('hidden');
|
||||
const modalCity = document.getElementById('modal-city-select');
|
||||
modalCity.parentElement.classList.add('hidden');
|
||||
modalCity.removeAttribute('required');
|
||||
}
|
||||
|
||||
let page = 1;
|
||||
let cities = [];
|
||||
let tenantBoundaries = {}; // maps city_municipality_id => points array
|
||||
let lguCityId = null; // Store city ID for LGU Admins
|
||||
|
||||
let leafletMap = null;
|
||||
let lguBoundaryLayer = null;
|
||||
@@ -166,13 +182,26 @@
|
||||
}
|
||||
|
||||
// Load Tenants to cache boundaries
|
||||
const tenantRes = await window.Verde.apiFetch('/api/v1/super-admin/tenants?per_page=100');
|
||||
if (tenantRes.ok) {
|
||||
tenantRes.body.data.forEach(t => {
|
||||
// Load Tenants to cache boundaries (Super Admin only for now, LGU Admins get boundary via /admin/settings/tenant equivalent or fetch-boundary)
|
||||
if (isSuperAdmin) {
|
||||
const tenantRes = await window.Verde.apiFetch('/api/v1/super-admin/tenants?per_page=100');
|
||||
if (tenantRes.ok) {
|
||||
tenantRes.body.data.forEach(t => {
|
||||
if (t.city_municipality_id && t.boundary_polygon) {
|
||||
tenantBoundaries[t.city_municipality_id] = t.boundary_polygon;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// LGU Admin: fetch own tenant boundary
|
||||
const tenantRes = await window.Verde.apiFetch('/api/v1/admin/settings/tenant');
|
||||
if (tenantRes.ok && tenantRes.body.data) {
|
||||
const t = tenantRes.body.data;
|
||||
lguCityId = t.city_municipality_id;
|
||||
if (t.city_municipality_id && t.boundary_polygon) {
|
||||
tenantBoundaries[t.city_municipality_id] = t.boundary_polygon;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +215,8 @@
|
||||
if (q) params.append('q', q);
|
||||
if (cityId) params.append('city_municipality_id', cityId);
|
||||
|
||||
const res = await window.Verde.apiFetch('/api/v1/super-admin/barangays?' + params);
|
||||
const endpoint = isSuperAdmin ? '/api/v1/super-admin/barangays?' : '/api/v1/admin/barangays?';
|
||||
const res = await window.Verde.apiFetch(endpoint + params);
|
||||
if (!res.ok) {
|
||||
rows.innerHTML = '<tr><td colspan="8" class="py-10 text-center text-sm text-red-500">Failed to load barangays.</td></tr>';
|
||||
return;
|
||||
@@ -372,7 +402,8 @@
|
||||
lguBoundaryLayer = null;
|
||||
}
|
||||
|
||||
const res = await window.Verde.apiFetch(`/api/v1/super-admin/barangays/${id}`);
|
||||
const endpoint = isSuperAdmin ? `/api/v1/super-admin/barangays/${id}` : `/api/v1/admin/barangays/${id}`;
|
||||
const res = await window.Verde.apiFetch(endpoint);
|
||||
if (!res.ok) {
|
||||
window.Verde.toast('Failed to load Barangay details.', 'error');
|
||||
return;
|
||||
@@ -422,7 +453,8 @@
|
||||
async function deleteBarangay(id) {
|
||||
if (!confirm('Are you sure you want to delete this Barangay?')) return;
|
||||
|
||||
const res = await window.Verde.apiFetch(`/api/v1/super-admin/barangays/${id}`, {
|
||||
const endpoint = isSuperAdmin ? `/api/v1/super-admin/barangays/${id}` : `/api/v1/admin/barangays/${id}`;
|
||||
const res = await window.Verde.apiFetch(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
@@ -446,7 +478,14 @@
|
||||
const payload = Object.fromEntries(fd.entries());
|
||||
payload.boundary = JSON.parse(boundaryInput.value);
|
||||
|
||||
const url = editingBarangayId ? `/api/v1/super-admin/barangays/${editingBarangayId}` : '/api/v1/super-admin/barangays';
|
||||
if (!isSuperAdmin && lguCityId) {
|
||||
payload.city_municipality_id = lguCityId;
|
||||
}
|
||||
|
||||
let url = isSuperAdmin ? '/api/v1/super-admin/barangays' : '/api/v1/admin/barangays';
|
||||
if (editingBarangayId) {
|
||||
url += `/${editingBarangayId}`;
|
||||
}
|
||||
const method = editingBarangayId ? 'PATCH' : 'POST';
|
||||
|
||||
const res = await window.Verde.apiFetch(url, {
|
||||
|
||||
144
resources/views/admin/lgu-dashboard.blade.php
Normal file
144
resources/views/admin/lgu-dashboard.blade.php
Normal file
@@ -0,0 +1,144 @@
|
||||
@extends('admin.layouts.app', ['pageTitle' => 'LGU Operations Dashboard'])
|
||||
|
||||
@section('page')
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-8">
|
||||
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">
|
||||
LGU Command Center
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Manage infrastructure and QR economy for your jurisdiction.</p>
|
||||
</header>
|
||||
|
||||
{{-- Stat cards --}}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<a href="/admin/households" class="card p-5 transition hover:border-orange-300 hover:shadow-sm">
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-orange-600">Pending Households</div>
|
||||
<div id="stat-pending-households" class="mt-2 text-2xl font-semibold tracking-tight text-neutral-900">...</div>
|
||||
</a>
|
||||
<a href="/admin/households" class="card p-5 transition hover:border-verde-300 hover:shadow-sm">
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-neutral-500">Active Households</div>
|
||||
<div id="stat-active-households" class="mt-2 text-2xl font-semibold tracking-tight text-neutral-900">...</div>
|
||||
</a>
|
||||
<a href="/admin/partner-stores" class="card p-5 transition hover:border-orange-300 hover:shadow-sm">
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-orange-600">Pending Stores</div>
|
||||
<div id="stat-pending-stores" class="mt-2 text-2xl font-semibold tracking-tight text-neutral-900">...</div>
|
||||
</a>
|
||||
<a href="/admin/qr-batches" class="card p-5 transition hover:border-verde-300 hover:shadow-sm">
|
||||
<div class="text-xs font-medium uppercase tracking-wider text-neutral-500">Total QR Batches</div>
|
||||
<div id="stat-total-qr-batches" class="mt-2 text-2xl font-semibold tracking-tight text-neutral-900">...</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="card-padded lg:col-span-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-neutral-900">Infrastructure Quick Links</h3>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<a href="/admin/barangays" class="flex items-center gap-3 p-4 border border-neutral-100 rounded-lg hover:bg-neutral-50 transition">
|
||||
<div class="h-10 w-10 rounded bg-verde-50 flex items-center justify-center text-verde-600">
|
||||
<!-- Icon -->
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1v2H9V7zm0 4h1v2H9v-2zm0 4h1v2H9v-2zm-2-8h1v2H7V7zm0 4h1v2H7v-2zm0 4h1v2H7v-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-neutral-900">Manage Barangays</div>
|
||||
<div class="text-xs text-neutral-500">Add or edit barangay boundaries</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/admin/service-areas" class="flex items-center gap-3 p-4 border border-neutral-100 rounded-lg hover:bg-neutral-50 transition">
|
||||
<div class="h-10 w-10 rounded bg-blue-50 flex items-center justify-center text-blue-600">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-neutral-900">Service Areas</div>
|
||||
<div class="text-xs text-neutral-500">Group barangays into zones</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/admin/drop-off-points" class="flex items-center gap-3 p-4 border border-neutral-100 rounded-lg hover:bg-neutral-50 transition">
|
||||
<div class="h-10 w-10 rounded bg-purple-50 flex items-center justify-center text-purple-600">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-neutral-900">Drop-off Points</div>
|
||||
<div class="text-xs text-neutral-500">Manage MRFs and drop-offs</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/admin/dumpsites" class="flex items-center gap-3 p-4 border border-neutral-100 rounded-lg hover:bg-neutral-50 transition">
|
||||
<div class="h-10 w-10 rounded bg-red-50 flex items-center justify-center text-red-600">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-neutral-900">Dumpsites</div>
|
||||
<div class="text-xs text-neutral-500">Configure landfill locations</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-padded">
|
||||
<h3 class="text-sm font-semibold text-neutral-900">Pending Action Required</h3>
|
||||
<div id="pending-actions-container" class="mt-4 divide-y divide-neutral-100">
|
||||
<div class="py-4 text-sm text-neutral-400">Loading...</div>
|
||||
</div>
|
||||
<div id="pending-actions-footer" class="mt-3 text-center hidden">
|
||||
<a href="/admin/households?verification_status=pending" class="text-xs font-medium text-verde-700 hover:text-verde-800">View more...</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
async function loadDashboard() {
|
||||
const res = await window.Verde.apiFetch('/api/v1/admin/dashboard');
|
||||
if (!res.ok) {
|
||||
window.Verde.toast('Failed to load dashboard metrics.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.body.data;
|
||||
document.getElementById('stat-pending-households').textContent = data.metrics.pending_households;
|
||||
document.getElementById('stat-active-households').textContent = data.metrics.active_households;
|
||||
document.getElementById('stat-pending-stores').textContent = data.metrics.pending_stores;
|
||||
document.getElementById('stat-total-qr-batches').textContent = data.metrics.total_qr_batches;
|
||||
|
||||
const container = document.getElementById('pending-actions-container');
|
||||
if (data.recent_pending_households.length === 0) {
|
||||
container.innerHTML = '<div class="py-4 text-sm text-neutral-400">All caught up! No pending households.</div>';
|
||||
} else {
|
||||
container.innerHTML = data.recent_pending_households.map(h => `
|
||||
<a href="/admin/households" class="block py-3 transition hover:bg-neutral-50 -mx-2 px-2 rounded">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-neutral-900">${h.full_name}</div>
|
||||
<div class="text-xs text-neutral-500">${h.address_line}</div>
|
||||
</div>
|
||||
<span class="badge ${h.has_proof ? 'badge-pending' : 'badge-inactive'}">
|
||||
${h.has_proof ? 'Has proof' : 'No proof'}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
`).join('');
|
||||
|
||||
if (data.metrics.pending_households > 5) {
|
||||
const footer = document.getElementById('pending-actions-footer');
|
||||
footer.classList.remove('hidden');
|
||||
footer.querySelector('a').textContent = `View ${data.metrics.pending_households - 5} more...`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
</script>
|
||||
@endsection
|
||||
@@ -123,7 +123,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Admin Password</label>
|
||||
<input name="admin_password" type="password" class="form-input" placeholder="Minimum 8 characters">
|
||||
<input name="admin_password" type="password" class="form-input" placeholder="Minimum 8 characters (Leave blank to keep current when editing)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -594,7 +594,7 @@
|
||||
|
||||
if (uuid) {
|
||||
document.getElementById('modal-title').textContent = 'Edit LGU details';
|
||||
adminFields.classList.add('hidden'); // Cannot edit admin credentials from here
|
||||
adminFields.classList.remove('hidden');
|
||||
|
||||
// Load current details
|
||||
const res = await window.Verde.apiFetch(`/api/v1/super-admin/tenants/${uuid}`);
|
||||
@@ -606,6 +606,8 @@
|
||||
form.elements.contact_phone.value = t.contact_phone;
|
||||
form.elements.status.value = t.status;
|
||||
form.elements.theme_color.value = t.theme_color || '#16a34a';
|
||||
if (form.elements.admin_email) form.elements.admin_email.value = t.admin_email || '';
|
||||
if (form.elements.admin_password) form.elements.admin_password.value = '';
|
||||
document.getElementById('theme-color-hex').value = (t.theme_color || '#16a34a').toUpperCase();
|
||||
|
||||
// Select regions/provinces if linked
|
||||
@@ -728,11 +730,6 @@
|
||||
|
||||
payload.city_municipality_id = parseInt(cityIdInput.value, 10);
|
||||
|
||||
if (editingTenantUuid) {
|
||||
delete payload.admin_email;
|
||||
delete payload.admin_password;
|
||||
}
|
||||
|
||||
const method = editingTenantUuid ? 'PATCH' : 'POST';
|
||||
const url = editingTenantUuid
|
||||
? `/api/v1/super-admin/tenants/${editingTenantUuid}`
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
],
|
||||
'Areas' => [
|
||||
['href' => '/admin/service-areas', 'label' => 'Service Areas', 'icon' => 'globe'],
|
||||
['href' => '/admin/barangays', 'label' => 'Barangays', 'icon' => 'pin'],
|
||||
],
|
||||
'Finance' => [
|
||||
['href' => '/admin/finance', 'label' => 'Payments', 'icon' => 'cash'],
|
||||
@@ -35,12 +36,10 @@
|
||||
],
|
||||
'Settings' => [
|
||||
['href' => '/admin/settings', 'label' => 'System Config', 'icon' => 'cog'],
|
||||
['href' => '/docs/api', 'label' => 'API Docs', 'icon' => 'search', 'external' => true],
|
||||
],
|
||||
'System Admin' => [
|
||||
['href' => '/admin/lgus', 'label' => 'LGU Management', 'icon' => 'globe'],
|
||||
['href' => '/admin/system-users', 'label' => 'User Accounts', 'icon' => 'users'],
|
||||
['href' => '/admin/barangays', 'label' => 'Barangay Config', 'icon' => 'pin'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -73,7 +72,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold tracking-tight text-neutral-900">Verde</span>
|
||||
<span class="ml-auto text-[10px] font-medium uppercase tracking-wider text-neutral-400">Admin</span>
|
||||
<span id="sidebar-role-badge" class="ml-auto text-[10px] font-medium uppercase tracking-wider text-neutral-400">Admin</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto px-3 pb-6">
|
||||
@@ -107,9 +106,21 @@
|
||||
</nav>
|
||||
|
||||
<script type="module">
|
||||
// Show LGU Management only for super_admin
|
||||
const user = window.Verde?.getUser();
|
||||
if (user && user.role === 'super_admin') {
|
||||
document.getElementById('nav-group-superadmin')?.classList.remove('hidden');
|
||||
if (user) {
|
||||
// Show System Admin group only for super_admin
|
||||
if (user.role === 'super_admin') {
|
||||
document.getElementById('nav-group-superadmin')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Update role badge
|
||||
const badge = document.getElementById('sidebar-role-badge');
|
||||
if (badge) {
|
||||
if (user.role === 'super_admin') {
|
||||
badge.textContent = 'Super';
|
||||
} else if (user.role === 'admin' && user.tenant && user.tenant.code) {
|
||||
badge.textContent = user.tenant.code + '-Admin';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -57,6 +57,29 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-padded mt-6">
|
||||
<h3 class="mb-4 text-base font-semibold text-neutral-900">Change password</h3>
|
||||
<form id="password-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="form-label">Current password</label>
|
||||
<input type="password" name="current_password" required class="form-input" placeholder="Current password">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">New password</label>
|
||||
<input type="password" name="password" required minlength="8" class="form-input" placeholder="Minimum 8 characters">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Confirm new password</label>
|
||||
<input type="password" name="password_confirmation" required minlength="8" class="form-input" placeholder="Must match new password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2">
|
||||
<button type="submit" class="btn-primary">Update password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-padded mt-6">
|
||||
<h3 class="mb-2 text-base font-semibold text-neutral-900">System</h3>
|
||||
<p class="text-sm text-neutral-500">
|
||||
@@ -99,6 +122,27 @@
|
||||
else window.Verde.toast(res.body?.message ?? 'Failed', 'error');
|
||||
});
|
||||
|
||||
const pwForm = document.getElementById('password-form');
|
||||
pwForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = Object.fromEntries(new FormData(pwForm).entries());
|
||||
if (payload.password !== payload.password_confirmation) {
|
||||
window.Verde.toast('Passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await window.Verde.apiFetch('/api/v1/me/password', {
|
||||
method: 'POST', body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.Verde.toast('Password updated successfully.', 'success');
|
||||
pwForm.reset();
|
||||
} else {
|
||||
window.Verde.toast(res.body?.message ?? 'Failed to update password.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
@@ -132,8 +132,9 @@
|
||||
if (!isSuperAdmin) {
|
||||
window.location.href = '/admin/dashboard';
|
||||
} else {
|
||||
document.getElementById('page-content').style.display = 'block';
|
||||
document.getElementById('page-content')?.style.setProperty('display', 'block');
|
||||
}
|
||||
|
||||
const rows = document.getElementById('rows');
|
||||
const modal = document.getElementById('form-modal');
|
||||
const form = document.getElementById('create-form');
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Http\Controllers\Api\V1\Admin\AdminTeamController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminTripController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminTruckController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminUserController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminBarangayController;
|
||||
use App\Http\Controllers\Api\V1\Admin\BulkActionController;
|
||||
use App\Http\Controllers\Api\V1\Auth\ChangePasswordController;
|
||||
use App\Http\Controllers\Api\V1\Auth\ForgotPasswordController;
|
||||
@@ -190,6 +191,18 @@ Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/payments')->nam
|
||||
Route::post('/{payment}/mark-paid', [PaymentController::class, 'adminMarkPaid'])->name('mark-paid');
|
||||
});
|
||||
|
||||
Route::prefix('admin')
|
||||
->name('api.v1.admin.')
|
||||
->middleware(['auth:sanctum', 'role:admin'])
|
||||
->group(function () {
|
||||
Route::get('dashboard', [\App\Http\Controllers\Api\V1\Admin\DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
// Settings / Preferences
|
||||
Route::get('settings/tenant', [\App\Http\Controllers\Api\V1\Admin\AdminTenantController::class, 'show'])->name('tenant.show');
|
||||
|
||||
Route::apiResource('barangays', AdminBarangayController::class);
|
||||
});
|
||||
|
||||
Route::prefix('admin/users')
|
||||
->name('api.v1.admin.users.')
|
||||
->middleware(['auth:sanctum', 'role:admin'])
|
||||
|
||||
@@ -14,7 +14,7 @@ Route::get('/qr/{serial}', function ($serial) {
|
||||
})->name('qr.verify');
|
||||
|
||||
Route::prefix('admin')->group(function () {
|
||||
Route::view('/dashboard', 'admin.dashboard')->name('admin.dashboard');
|
||||
Route::view('/dashboard', 'admin.lgu-dashboard')->name('admin.dashboard');
|
||||
Route::view('/households', 'admin.households')->name('admin.households');
|
||||
Route::view('/service-areas', 'admin.service-areas')->name('admin.service-areas');
|
||||
Route::view('/qr-batches', 'admin.qr-batches')->name('admin.qr-batches');
|
||||
|
||||
Reference in New Issue
Block a user