From ee72cd72115cbc3eb027692e630730690b0d3be4 Mon Sep 17 00:00:00 2001 From: Azure_0_ Date: Tue, 7 Jul 2026 00:33:16 +0800 Subject: [PATCH] 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 --- .../Api/V1/Admin/AdminBarangayController.php | 236 ++++++++++++++++++ .../Api/V1/Admin/AdminQrBatchController.php | 19 ++ .../Api/V1/Admin/AdminTenantController.php | 32 +++ .../Api/V1/Admin/DashboardController.php | 57 +++++ .../Api/V1/Auth/RegisterController.php | 2 + .../SuperAdmin/SuperAdminTenantController.php | 33 +++ .../SuperAdmin/StoreTenantRequest.php | 9 +- app/Services/Qr/QrAllocator.php | 2 +- config/qr.php | 2 +- lgu-dashboard-qr.md | 69 +++++ package-lock.json | 2 +- resources/views/admin/barangays.blade.php | 57 ++++- resources/views/admin/lgu-dashboard.blade.php | 144 +++++++++++ resources/views/admin/lgus.blade.php | 11 +- .../views/admin/partials/sidebar.blade.php | 23 +- resources/views/admin/settings.blade.php | 44 ++++ resources/views/admin/system-users.blade.php | 3 +- routes/api.php | 13 + routes/web.php | 2 +- 19 files changed, 730 insertions(+), 30 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AdminTenantController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/DashboardController.php create mode 100644 lgu-dashboard-qr.md create mode 100644 resources/views/admin/lgu-dashboard.blade.php diff --git a/app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php b/app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php new file mode 100644 index 0000000..fae5ba2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php @@ -0,0 +1,236 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php b/app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php index e5caf7d..40caff1 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php @@ -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 diff --git a/app/Http/Controllers/Api/V1/Admin/AdminTenantController.php b/app/Http/Controllers/Api/V1/Admin/AdminTenantController.php new file mode 100644 index 0000000..bc9dd95 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminTenantController.php @@ -0,0 +1,32 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/DashboardController.php b/app/Http/Controllers/Api/V1/Admin/DashboardController.php new file mode 100644 index 0000000..a2ea045 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/DashboardController.php @@ -0,0 +1,57 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/RegisterController.php b/app/Http/Controllers/Api/V1/Auth/RegisterController.php index 1ab18c2..320f1dd 100644 --- a/app/Http/Controllers/Api/V1/Auth/RegisterController.php +++ b/app/Http/Controllers/Api/V1/Auth/RegisterController.php @@ -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; diff --git a/app/Http/Controllers/Api/V1/SuperAdmin/SuperAdminTenantController.php b/app/Http/Controllers/Api/V1/SuperAdmin/SuperAdminTenantController.php index 38d8f3b..9455186 100644 --- a/app/Http/Controllers/Api/V1/SuperAdmin/SuperAdminTenantController.php +++ b/app/Http/Controllers/Api/V1/SuperAdmin/SuperAdminTenantController.php @@ -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']); diff --git a/app/Http/Requests/SuperAdmin/StoreTenantRequest.php b/app/Http/Requests/SuperAdmin/StoreTenantRequest.php index c9dd637..47df0e6 100644 --- a/app/Http/Requests/SuperAdmin/StoreTenantRequest.php +++ b/app/Http/Requests/SuperAdmin/StoreTenantRequest.php @@ -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'], ]; } } diff --git a/app/Services/Qr/QrAllocator.php b/app/Services/Qr/QrAllocator.php index 91d1bcd..d4f7a44 100644 --- a/app/Services/Qr/QrAllocator.php +++ b/app/Services/Qr/QrAllocator.php @@ -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 diff --git a/config/qr.php b/config/qr.php index 01c40cd..11e7eda 100644 --- a/config/qr.php +++ b/config/qr.php @@ -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, diff --git a/lgu-dashboard-qr.md b/lgu-dashboard-qr.md new file mode 100644 index 0000000..ebbb3de --- /dev/null +++ b/lgu-dashboard-qr.md @@ -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. diff --git a/package-lock.json b/package-lock.json index f6cd493..15e7933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "verde-web", + "name": "Web", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/views/admin/barangays.blade.php b/resources/views/admin/barangays.blade.php index 3bb2c8b..173f604 100644 --- a/resources/views/admin/barangays.blade.php +++ b/resources/views/admin/barangays.blade.php @@ -23,7 +23,7 @@
- @@ -134,9 +134,25 @@
+@endsection diff --git a/resources/views/admin/lgus.blade.php b/resources/views/admin/lgus.blade.php index 178a7ac..00e2127 100644 --- a/resources/views/admin/lgus.blade.php +++ b/resources/views/admin/lgus.blade.php @@ -123,7 +123,7 @@
- +
@@ -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}` diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index 328c447..6c557cb 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -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 @@ Verde - Admin + Admin diff --git a/resources/views/admin/settings.blade.php b/resources/views/admin/settings.blade.php index a5b52e2..5efac71 100644 --- a/resources/views/admin/settings.blade.php +++ b/resources/views/admin/settings.blade.php @@ -57,6 +57,29 @@ +
+

Change password

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+

System

@@ -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(); @endsection diff --git a/resources/views/admin/system-users.blade.php b/resources/views/admin/system-users.blade.php index f13b77f..b0c9946 100644 --- a/resources/views/admin/system-users.blade.php +++ b/resources/views/admin/system-users.blade.php @@ -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'); diff --git a/routes/api.php b/routes/api.php index 6bff767..0edbbfa 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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']) diff --git a/routes/web.php b/routes/web.php index 88ab8fd..eadf330 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');