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