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']);
|
||||
|
||||
Reference in New Issue
Block a user