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:
Azure_0_
2026-07-07 00:33:16 +08:00
committed by Verde AI
parent 6f74305e4a
commit ee72cd7211
19 changed files with 730 additions and 30 deletions

View 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);
}
}

View File

@@ -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

View 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,
]);
}
}

View 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,
]);
}
}

View File

@@ -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;

View File

@@ -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']);

View File

@@ -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'],
];
}
}

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -1,5 +1,5 @@
{
"name": "verde-web",
"name": "Web",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@@ -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, {

View 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

View File

@@ -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}`

View File

@@ -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>

View File

@@ -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

View File

@@ -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');

View File

@@ -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'])

View File

@@ -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');