From 7f3357206f262ec079c7965d8d783b5cff3ea3de Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 3 Jul 2026 11:19:08 +0800 Subject: [PATCH] feat: implement fixed dashboard layout, custom geofencing shapes, and QC seeder - UI: Refactored admin layout to a fixed 'App Shell' design with independent scrollbars for sidebar and content area. - UI: Cleaned up sidebar partial structure to support nested scrolling. - GEOFENCING: Added support for Circle and Rectangle tools in LGU configuration. - GEOFENCING: Implemented high-precision (128-point) circle-to-polygon conversion for database storage. - PAGINATION: Integrated standard Laravel pagination across Teams and Reports tables. - PAGINATION: Added a global 'renderPagination' helper and 'Per Page' selector (25, 50, 100). - DATA: Created QuezonCitySeeder with 142 official barangays and 3 months of historical collection/sales data. - FIX: Corrected API total aggregations in reports to remain accurate during pagination. - FIX: Resolved unique constraint and axis-order issues in spatial data seeders. --- .../Api/V1/Admin/AdminReportController.php | 214 +++++++++------ database/seeders/DatabaseSeeder.php | 1 + database/seeders/QuezonCitySeeder.php | 163 ++++++++++++ database/seeders/ReportDataSeeder.php | 151 +++++++++++ .../seeders/SimulationContinuationSeeder.php | 250 ++++++++++++++++++ resources/js/admin.js | 63 +++++ resources/views/admin/layouts/app.blade.php | 10 +- resources/views/admin/lgus.blade.php | 48 +++- .../views/admin/partials/sidebar.blade.php | 74 +++--- resources/views/admin/reports.blade.php | 225 +++++++++++++--- resources/views/admin/teams.blade.php | 35 ++- walkthrough.md | 21 +- 12 files changed, 1090 insertions(+), 165 deletions(-) create mode 100644 database/seeders/QuezonCitySeeder.php create mode 100644 database/seeders/ReportDataSeeder.php create mode 100644 database/seeders/SimulationContinuationSeeder.php diff --git a/app/Http/Controllers/Api/V1/Admin/AdminReportController.php b/app/Http/Controllers/Api/V1/Admin/AdminReportController.php index 525e767..8c98c1f 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminReportController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminReportController.php @@ -7,6 +7,8 @@ use App\Models\DailyCollectionStat; use App\Models\DumpsiteRelease; use App\Models\MonthlyStoreSale; use App\Models\WeeklyRoutePerformance; +use App\Models\Tenant; +use App\Tenancy\Tenancy; use App\Services\Report\Aggregator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -23,33 +25,48 @@ class AdminReportController extends ApiController 'from' => ['nullable', 'date'], 'to' => ['nullable', 'date'], 'barangay_id' => ['nullable', 'integer'], + 'tenant_id' => ['nullable', 'integer'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], ]); $from = isset($data['from']) ? Carbon::parse($data['from'])->toDateString() : Carbon::now()->subDays(30)->toDateString(); $to = isset($data['to']) ? Carbon::parse($data['to'])->toDateString() : Carbon::today()->toDateString(); + $perPage = (int) ($data['per_page'] ?? 50); - $rows = DailyCollectionStat::query() - ->whereBetween('date', [$from, $to]) - ->when($data['barangay_id'] ?? null, fn ($q, $id) => $q->where('barangay_id', $id)) - ->orderBy('date') - ->get(); + $tenant = $this->resolveTargetTenant($request); - return $this->ok([ - 'from' => $from, - 'to' => $to, - 'series' => $rows->map(fn ($r) => [ - 'date' => $r->date->toDateString(), - 'barangay_id' => $r->barangay_id, - 'total_scans' => $r->total_scans, - 'total_weight_kg' => $r->total_weight_kg, - 'unique_households' => $r->unique_households, - 'missed_pickups' => $r->missed_pickups, - ]), - 'totals' => [ - 'total_scans' => (int) $rows->sum('total_scans'), - 'total_weight_kg' => (int) $rows->sum('total_weight_kg'), - 'unique_households' => (int) $rows->sum('unique_households'), - ], - ]); + return Tenancy::withTenant($tenant, function () use ($from, $to, $data, $perPage) { + $query = DailyCollectionStat::query() + ->whereBetween('date', [$from, $to]) + ->when($data['barangay_id'] ?? null, fn ($q, $id) => $q->where('barangay_id', $id)); + + $totals = [ + 'total_scans' => (int) $query->sum('total_scans'), + 'total_weight_kg' => (int) $query->sum('total_weight_kg'), + 'unique_households' => (int) $query->sum('unique_households'), + ]; + + $rows = $query->orderBy('date', 'desc')->paginate($perPage); + + return $this->ok( + $rows->map(fn ($r) => [ + 'date' => $r->date->toDateString(), + 'barangay_id' => $r->barangay_id, + 'total_scans' => $r->total_scans, + 'total_weight_kg' => $r->total_weight_kg, + 'unique_households' => $r->unique_households, + 'missed_pickups' => $r->missed_pickups, + ]), + null, + array_merge($totals, [ + 'page' => $rows->currentPage(), + 'per_page' => $rows->perPage(), + 'total' => $rows->total(), + 'last_page' => $rows->lastPage(), + 'from' => $from, + 'to' => $to, + ]) + ); + }); } public function tripPerformance(Request $request): JsonResponse @@ -58,28 +75,42 @@ class AdminReportController extends ApiController 'from' => ['nullable', 'date'], 'to' => ['nullable', 'date'], 'route_id' => ['nullable', 'integer'], + 'tenant_id' => ['nullable', 'integer'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], ]); $from = isset($data['from']) ? Carbon::parse($data['from'])->toDateString() : Carbon::now()->subWeeks(8)->toDateString(); $to = isset($data['to']) ? Carbon::parse($data['to'])->toDateString() : Carbon::today()->toDateString(); + $perPage = (int) ($data['per_page'] ?? 50); - $rows = WeeklyRoutePerformance::with('route') - ->whereBetween('week_start_date', [$from, $to]) - ->when($data['route_id'] ?? null, fn ($q, $id) => $q->where('route_id', $id)) - ->orderBy('week_start_date') - ->get(); + $tenant = $this->resolveTargetTenant($request); - return $this->ok([ - 'from' => $from, - 'to' => $to, - 'series' => $rows->map(fn ($r) => [ - 'week_start' => $r->week_start_date->toDateString(), - 'route_code' => $r->route?->code, - 'on_time_rate_percent' => $r->on_time_rate_percent, - 'avg_trip_duration_minutes' => $r->avg_trip_duration_minutes, - 'completion_rate_percent' => $r->completion_rate_percent, - 'trips_count' => $r->trips_count, - ]), - ]); + return Tenancy::withTenant($tenant, function () use ($from, $to, $data, $perPage) { + $rows = WeeklyRoutePerformance::with('route') + ->whereBetween('week_start_date', [$from, $to]) + ->when($data['route_id'] ?? null, fn ($q, $id) => $q->where('route_id', $id)) + ->orderBy('week_start_date', 'desc') + ->paginate($perPage); + + return $this->ok( + $rows->map(fn ($r) => [ + 'week_start' => $r->week_start_date->toDateString(), + 'route_code' => $r->route?->code, + 'on_time_rate_percent' => $r->on_time_rate_percent, + 'avg_trip_duration_minutes' => $r->avg_trip_duration_minutes, + 'completion_rate_percent' => $r->completion_rate_percent, + 'trips_count' => $r->trips_count, + ]), + null, + [ + 'page' => $rows->currentPage(), + 'per_page' => $rows->perPage(), + 'total' => $rows->total(), + 'last_page' => $rows->lastPage(), + 'from' => $from, + 'to' => $to, + ] + ); + }); } public function storeSales(Request $request): JsonResponse @@ -88,32 +119,47 @@ class AdminReportController extends ApiController 'from' => ['nullable', 'date'], 'to' => ['nullable', 'date'], 'store_id' => ['nullable', 'integer'], + 'tenant_id' => ['nullable', 'integer'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], ]); $from = isset($data['from']) ? Carbon::parse($data['from'])->startOfMonth()->toDateString() : Carbon::now()->subMonths(6)->startOfMonth()->toDateString(); $to = isset($data['to']) ? Carbon::parse($data['to'])->endOfMonth()->toDateString() : Carbon::today()->endOfMonth()->toDateString(); + $perPage = (int) ($data['per_page'] ?? 50); - $rows = MonthlyStoreSale::with('store') - ->whereBetween('month_start_date', [$from, $to]) - ->when($data['store_id'] ?? null, fn ($q, $id) => $q->where('store_id', $id)) - ->orderBy('month_start_date') - ->get(); + $tenant = $this->resolveTargetTenant($request); - return $this->ok([ - 'from' => $from, - 'to' => $to, - 'series' => $rows->map(fn ($r) => [ - 'month' => $r->month_start_date->toDateString(), - 'store_name' => $r->store?->business_name, - 'quantity' => $r->total_quantity_sold, - 'retail_centavos' => $r->total_retail_centavos, - 'commission_centavos' => $r->total_commission_centavos, - ]), - 'totals' => [ - 'quantity' => (int) $rows->sum('total_quantity_sold'), - 'retail_centavos' => (int) $rows->sum('total_retail_centavos'), - 'commission_centavos' => (int) $rows->sum('total_commission_centavos'), - ], - ]); + return Tenancy::withTenant($tenant, function () use ($from, $to, $data, $perPage) { + $query = MonthlyStoreSale::with('store') + ->whereBetween('month_start_date', [$from, $to]) + ->when($data['store_id'] ?? null, fn ($q, $id) => $q->where('store_id', $id)); + + $totals = [ + 'total_quantity' => (int) $query->sum('total_quantity_sold'), + 'total_retail_centavos' => (int) $query->sum('total_retail_centavos'), + 'total_commission_centavos' => (int) $query->sum('total_commission_centavos'), + ]; + + $rows = $query->orderBy('month_start_date', 'desc')->paginate($perPage); + + return $this->ok( + $rows->map(fn ($r) => [ + 'month' => $r->month_start_date->toDateString(), + 'store_name' => $r->store?->business_name, + 'quantity' => $r->total_quantity_sold, + 'retail_centavos' => $r->total_retail_centavos, + 'commission_centavos' => $r->total_commission_centavos, + ]), + null, + array_merge($totals, [ + 'page' => $rows->currentPage(), + 'per_page' => $rows->perPage(), + 'total' => $rows->total(), + 'last_page' => $rows->lastPage(), + 'from' => $from, + 'to' => $to, + ]) + ); + }); } public function complianceCsv(Request $request): StreamedResponse @@ -121,35 +167,40 @@ class AdminReportController extends ApiController $data = $request->validate([ 'from' => ['required', 'date'], 'to' => ['required', 'date'], + 'tenant_id' => ['nullable', 'integer'], ]); $from = Carbon::parse($data['from'])->startOfDay(); $to = Carbon::parse($data['to'])->endOfDay(); + $tenant = $this->resolveTargetTenant($request); + $headers = [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="verde-compliance-'.$from->toDateString().'-'.$to->toDateString().'.csv"', ]; - return response()->streamDownload(function () use ($from, $to) { + return response()->streamDownload(function () use ($from, $to, $tenant) { $out = fopen('php://output', 'w'); fputcsv($out, ['released_at', 'trip_number', 'dumpsite', 'permit_number', 'weight_kg', 'gate_pass', 'attendant']); - DumpsiteRelease::with(['dumpsite', 'trip']) - ->whereBetween('released_at', [$from, $to]) - ->orderBy('released_at') - ->chunk(500, function ($rows) use ($out) { - foreach ($rows as $r) { - fputcsv($out, [ - $r->released_at?->toIso8601String(), - $r->trip?->trip_number, - $r->dumpsite?->name, - $r->dumpsite?->permit_number, - $r->weight_kg, - $r->gate_pass_number, - $r->dumpsite_attendant_name, - ]); - } - }); + Tenancy::withTenant($tenant, function () use ($from, $to, $out) { + DumpsiteRelease::with(['dumpsite', 'trip']) + ->whereBetween('released_at', [$from, $to]) + ->orderBy('released_at') + ->chunk(500, function ($rows) use ($out) { + foreach ($rows as $r) { + fputcsv($out, [ + $r->released_at?->toIso8601String(), + $r->trip?->trip_number, + $r->dumpsite?->name, + $r->dumpsite?->permit_number, + $r->weight_kg, + $r->gate_pass_number, + $r->dumpsite_attendant_name, + ]); + } + }); + }); fclose($out); }, 'verde-compliance.csv', $headers); @@ -172,4 +223,13 @@ class AdminReportController extends ApiController 'monthly_buckets' => $monthly, ], 'Aggregations rebuilt'); } + + private function resolveTargetTenant(Request $request): ?Tenant + { + if (optional($request->user())->isSuperAdmin() && $request->filled('tenant_id')) { + return Tenant::find($request->input('tenant_id')); + } + + return Tenancy::current(); + } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 612c776..e614422 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,6 +12,7 @@ class DatabaseSeeder extends Seeder RoleSeeder::class, SamplePsgcSeeder::class, DevelopmentSeeder::class, + QuezonCitySeeder::class, ]); } } diff --git a/database/seeders/QuezonCitySeeder.php b/database/seeders/QuezonCitySeeder.php new file mode 100644 index 0000000..c4bc3d1 --- /dev/null +++ b/database/seeders/QuezonCitySeeder.php @@ -0,0 +1,163 @@ +first(); + if (!$city) { + $this->command->error("City 'Quezon City' not found in master data."); + return; + } + + // 2. Find the user-created Quezon City LGU (Tenant) + $tenant = Tenant::where('name', 'Quezon City')->first(); + if (!$tenant) { + $this->command->error("Tenant 'Quezon City' not found. Please create it first in LGU Management."); + return; + } + + // Ensure it's linked to the city + $tenant->update(['city_municipality_id' => $city->id]); + + // 3. Define all 142 Barangays + $barangayNames = [ + 'Alicia', 'Bagong Pag-asa', 'Bahay Toro', 'Balingasa', 'Bungad', 'Damar', 'Damayan', 'Del Monte', 'Katipunan', 'Lourdes', 'Maharlika', 'Manresa', 'Mariblo', 'Masambong', 'N.S. Amoranto', 'Nayong Kanluran', 'Paang Bundok', 'Pag-ibig sa Nayon', 'Paltok', 'Paraiso', 'Phil-Am', 'Project 6', 'Ramon Magsaysay', 'Saint Peter', 'Salvacion', 'San Antonio', 'San Isidro Labrador', 'San Jose', 'Santa Cruz', 'Santa Teresita', 'Sto. Cristo', 'Santo Domingo', 'Siena', 'Talayan', 'Vasra', 'Veterans Village', 'West Triangle', + 'Bagong Silangan', 'Batasan Hills', 'Commonwealth', 'Holy Spirit', 'Payatas', + 'Amihan', 'Bagumbayan', 'Bagumbuhay', 'Bayanihan', 'Blue Ridge A', 'Blue Ridge B', 'Camp Aguinaldo', 'Claro', 'Dioquino Zobel', 'Duyan-duyan', 'E. Rodriguez', 'East Kamias', 'Escopa I', 'Escopa II', 'Escopa III', 'Escopa IV', 'Libis', 'Loyola Heights', 'Mangga', 'Marilag', 'Masagana', 'Matandang Balara', 'Milagrosa', 'Pansol', 'Quirino 2-A', 'Quirino 2-B', 'Quirino 2-C', 'Quirino 3-A', 'Saint Ignatius', 'San Roque', 'Silangan', 'Socorro', 'Tagumpay', 'Ugong Norte', 'Villa Maria Clara', 'West Kamias', 'White Plains', + 'Bagong Lipunan ng Crame', 'Botocan', 'Central', 'Damayang Lagi', 'Don Manuel', 'Doña Aurora', 'Doña Imelda', 'Doña Josefa', 'Galas', 'Gesù Maria Josef', 'Horsehoe', 'Immaculate Concepcion', 'Kalusugan', 'Kamuning', 'Kaunlaran', 'Kristong Hari', 'Laging Handa', 'Malaya', 'Mariana', 'Obrero', 'Old Capitol Site', 'Paligsahan', 'Pinagkaisahan', 'Pinyahan', 'Roxas', 'Sacred Heart', 'San Isidro', 'San Martin de Porres', 'Santol', 'Sikatuna Village', 'South Triangle', 'Sto. Niño', 'Tatalon', 'Teachers Village East', 'Teachers Village West', 'U.P. Campus', 'U.P. Village', 'Valencia', + 'Bagbag', 'Capri', 'Fairview', 'Greater Lagro', 'Gulod', 'Kaligayahan', 'Nagkaisang Nayon', 'North Fairview', 'Novaliches Proper', 'Pasong Putik Proper', 'San Agustin', 'San Bartolome', 'Santa Lucia', 'Santa Monica', + 'Apolonio Samson', 'Baesa', 'Balon-Bato', 'Culiat', 'New Era', 'Pasong Tamo', 'Sangandaan', 'Sauyo', 'Tandang Sora', 'Unang Sigaw', 'Talipapa' + ]; + + $barangays = []; + $psgcBase = 137404000; + + // Quezon City approximate center + $baseLat = 14.6760; + $baseLng = 121.0437; + + foreach ($barangayNames as $index => $name) { + // Check if barangay exists to avoid PSGC/Code duplication + $existing = Barangay::where('city_municipality_id', $city->id)->where('name', $name)->first(); + + if ($existing) { + $barangays[] = $existing; + continue; + } + + // Randomize position slightly + $lat = $baseLat + (rand(-1000, 1000) / 10000); + $lng = $baseLng + (rand(-1000, 1000) / 10000); + + $boundary = new Polygon([ + new LineString([ + new Point($lat - 0.005, $lng - 0.005, 4326), + new Point($lat + 0.005, $lng - 0.005, 4326), + new Point($lat + 0.005, $lng + 0.005, 4326), + new Point($lat - 0.005, $lng + 0.005, 4326), + new Point($lat - 0.005, $lng - 0.005, 4326), + ], 4326) + ], 4326); + + // Generate a 16-character unique code + $code = strtoupper('QC-' . Str::slug($name)); + if (strlen($code) > 16) { + $code = substr($code, 0, 12) . strtoupper(Str::random(4)); + } + + while (Barangay::where('code', $code)->exists()) { + $code = substr($code, 0, 12) . strtoupper(Str::random(4)); + } + + $barangays[] = Barangay::create([ + 'city_municipality_id' => $city->id, + 'name' => $name, + 'code' => $code, + 'psgc_code' => $psgcBase + ($index + 1) + 5000, + 'urban_rural' => 'urban', + 'boundary' => $boundary, + 'centroid' => new Point($lat, $lng, 4326), + ]); + } + + // 4. Find or create an owner user for the store + $owner = User::where('tenant_id', $tenant->id)->where('role', User::ROLE_STORE_PARTNER)->first() + ?? User::where('tenant_id', $tenant->id)->first() + ?? User::where('role', User::ROLE_SUPER_ADMIN)->first(); + + // 5. Create a sample Partner Store + $store = PartnerStore::updateOrCreate( + ['tenant_id' => $tenant->id, 'business_name' => 'QC Central Hub'], + [ + 'owner_user_id' => $owner->id, + 'status' => PartnerStore::STATUS_ACTIVE, + 'commission_rate_percent' => 10, + 'address_line' => 'Quezon City Hall Complex', + 'barangay_id' => $barangays[0]->id, + 'coordinates' => new Point($baseLat, $baseLng, 4326), + ] + ); + + // 6. Seed 3 months of historical data + $start = Carbon::now()->subMonths(3)->startOfMonth(); + $end = Carbon::now(); + + $this->command->info("Seeding 3 months of historical reports for {$tenant->name}..."); + + for ($date = $start->copy(); $date->lte($end); $date->addDay()) { + $activeBarangays = collect($barangays)->random(15); + + foreach ($activeBarangays as $brgy) { + // Use withoutGlobalScopes to bypass Tenancy filter on unique check + DailyCollectionStat::withoutGlobalScopes()->updateOrCreate( + [ + 'barangay_id' => $brgy->id, + 'date' => $date->toDateString(), + ], + [ + 'tenant_id' => $tenant->id, + 'total_scans' => rand(100, 1500), + 'total_weight_kg' => rand(500, 5000), + 'unique_households' => rand(80, 1000), + 'missed_pickups' => rand(0, 50), + ] + ); + } + + if ($date->day === 1) { + MonthlyStoreSale::withoutGlobalScopes()->updateOrCreate( + [ + 'store_id' => $store->id, + 'month_start_date' => $date->toDateString(), + ], + [ + 'tenant_id' => $tenant->id, + 'total_quantity_sold' => rand(1000, 5000), + 'total_retail_centavos' => rand(500000, 2500000), + 'total_commission_centavos' => rand(50000, 250000), + ] + ); + } + } + + $this->command->info("Quezon City seeder completed successfully!"); + } +} diff --git a/database/seeders/ReportDataSeeder.php b/database/seeders/ReportDataSeeder.php new file mode 100644 index 0000000..6265aa6 --- /dev/null +++ b/database/seeders/ReportDataSeeder.php @@ -0,0 +1,151 @@ +delete(); + WeeklyRoutePerformance::where('tenant_id', $tenantId)->delete(); + MonthlyStoreSale::where('tenant_id', $tenantId)->delete(); + + $barangays = Barangay::limit(5)->get(); + $routes = Route::where('tenant_id', $tenantId)->get(); + $stores = PartnerStore::where('tenant_id', $tenantId)->get(); + $dumpsite = Dumpsite::where('tenant_id', $tenantId)->first(); + $driver = User::where('role', User::ROLE_DRIVER)->where('tenant_id', $tenantId)->first(); + + if ($barangays->isEmpty() || $routes->isEmpty() || $stores->isEmpty() || !$dumpsite || !$driver) { + $this->command->warn('Prerequisites for ReportDataSeeder missing. Run SimulationContinuationSeeder first.'); + return; + } + + $this->seedDailyStats($tenantId, $barangays); + $this->seedWeeklyPerformance($tenantId, $routes); + $this->seedMonthlySales($tenantId, $stores); + $this->seedDumpsiteReleases($tenantId, $dumpsite, $driver); + + $this->command->info('Report data seeded successfully!'); + } + + private function seedDailyStats($tenantId, $barangays): void + { + $this->command->info('Seeding Daily Collection Stats...'); + for ($i = 30; $i >= 0; $i--) { + $date = Carbon::now()->subDays($i); + foreach ($barangays as $barangay) { + $baseScans = 150 + rand(-50, 100); + $baseWeight = $baseScans * 5 + rand(-50, 50); + + DailyCollectionStat::withoutGlobalScopes()->updateOrCreate( + ['date' => $date->toDateString(), 'barangay_id' => $barangay->id], + [ + 'tenant_id' => $tenantId, + 'total_scans' => $baseScans, + 'total_weight_kg' => $baseWeight, + 'unique_households' => (int)($baseScans * 0.8), + 'missed_pickups' => rand(0, 10), + ] + ); + } + } + } + + private function seedWeeklyPerformance($tenantId, $routes): void + { + $this->command->info('Seeding Weekly Route Performance...'); + for ($i = 8; $i >= 0; $i--) { + $weekStart = Carbon::now()->subWeeks($i)->startOfWeek(); + foreach ($routes as $route) { + WeeklyRoutePerformance::withoutGlobalScopes()->updateOrCreate( + ['week_start_date' => $weekStart->toDateString(), 'route_id' => $route->id], + [ + 'tenant_id' => $tenantId, + 'on_time_rate_percent' => rand(85, 98), + 'avg_trip_duration_minutes' => rand(150, 210), + 'completion_rate_percent' => rand(95, 100), + 'trips_count' => 6, + ] + ); + } + } + } + + private function seedMonthlySales($tenantId, $stores): void + { + $this->command->info('Seeding Monthly Store Sales...'); + for ($i = 6; $i >= 0; $i--) { + $monthStart = Carbon::now()->subMonths($i)->startOfMonth(); + foreach ($stores as $store) { + $qty = 200 + rand(-50, 300); + $retail = $qty * 1500; + $commission = (int)($retail * ($store->commission_rate_percent / 100)); + + MonthlyStoreSale::withoutGlobalScopes()->updateOrCreate( + ['month_start_date' => $monthStart->toDateString(), 'store_id' => $store->id], + [ + 'tenant_id' => $tenantId, + 'total_quantity_sold' => $qty, + 'total_retail_centavos' => $retail, + 'total_commission_centavos' => $commission, + ] + ); + } + } + } + + private function seedDumpsiteReleases($tenantId, $dumpsite, $driver): void + { + $this->command->info('Seeding Dumpsite Releases...'); + for ($i = 20; $i >= 1; $i--) { + $releasedAt = Carbon::now()->subDays($i)->setTime(11, 30); + $trip = Trip::firstOrCreate( + ['trip_number' => "HIST-TRIP-" . $releasedAt->format('Ymd')], + [ + 'uuid' => (string) Str::uuid(), + 'tenant_id' => $tenantId, + 'route_id' => Route::where('tenant_id', $tenantId)->first()->id, + 'team_id' => 1, + 'truck_id' => 1, + 'scheduled_date' => $releasedAt->toDateString(), + 'status' => Trip::STATUS_COMPLETED, + 'actual_end_time' => $releasedAt, + 'end_reason' => 'completed' + ] + ); + + DumpsiteRelease::updateOrCreate( + ['tenant_id' => $tenantId, 'trip_id' => $trip->id], + [ + 'dumpsite_id' => $dumpsite->id, + 'released_at' => $releasedAt, + 'released_by_driver_id' => $driver->id, + 'weight_kg' => 4500 + rand(-500, 500), + 'waste_type_breakdown' => ['mixed' => 70, 'recyclable' => 20, 'organic' => 10], + 'gate_pass_number' => "GP-" . strtoupper(Str::random(8)), + 'dumpsite_attendant_name' => "Attendant " . rand(1, 5), + 'coordinates_at_release' => new Point(6.1136, 125.1719, 4326), + ] + ); + } + } +} diff --git a/database/seeders/SimulationContinuationSeeder.php b/database/seeders/SimulationContinuationSeeder.php new file mode 100644 index 0000000..46b3edf --- /dev/null +++ b/database/seeders/SimulationContinuationSeeder.php @@ -0,0 +1,250 @@ + 'driver_a@verde.local'], + [ + 'tenant_id' => $tenantId, + 'first_name' => 'Driver', + 'last_name' => 'Alpha', + 'phone' => '+639111111111', + 'password' => $password, + 'role' => User::ROLE_DRIVER, + 'status' => User::STATUS_ACTIVE, + ] + ); + + $scannerA = User::updateOrCreate( + ['email' => 'scanner_a@verde.local'], + [ + 'tenant_id' => $tenantId, + 'first_name' => 'Scanner', + 'last_name' => 'Alpha', + 'phone' => '+639111111112', + 'password' => $password, + 'role' => User::ROLE_SCANNER, + 'status' => User::STATUS_ACTIVE, + ] + ); + + $driverB = User::updateOrCreate( + ['email' => 'driver_b@verde.local'], + [ + 'tenant_id' => $tenantId, + 'first_name' => 'Driver', + 'last_name' => 'Bravo', + 'phone' => '+639222222221', + 'password' => $password, + 'role' => User::ROLE_DRIVER, + 'status' => User::STATUS_ACTIVE, + ] + ); + + $scannerB = User::updateOrCreate( + ['email' => 'scanner_b@verde.local'], + [ + 'tenant_id' => $tenantId, + 'first_name' => 'Scanner', + 'last_name' => 'Bravo', + 'phone' => '+639222222222', + 'password' => $password, + 'role' => User::ROLE_SCANNER, + 'status' => User::STATUS_ACTIVE, + ] + ); + + // 2. Setup Teams + $truck = Truck::firstOrCreate( + ['plate_number' => 'GEN-001'], + [ + 'tenant_id' => $tenantId, + 'model' => 'Isuzu Elf', + 'capacity_kg' => 5000, + 'status' => 'active' + ] + ); + + $teamA = CollectionTeam::updateOrCreate( + ['name' => 'GenSan Team Alpha'], + [ + 'tenant_id' => $tenantId, + 'driver_id' => $driverA->id, + 'scanner_id' => $scannerA->id, + 'truck_id' => $truck->id, + 'status' => CollectionTeam::STATUS_ACTIVE, + ] + ); + + $teamB = CollectionTeam::updateOrCreate( + ['name' => 'GenSan Team Bravo'], + [ + 'tenant_id' => $tenantId, + 'driver_id' => $driverB->id, + 'scanner_id' => $scannerB->id, + 'truck_id' => null, // Will assign a truck later or use same + 'status' => CollectionTeam::STATUS_ACTIVE, + ] + ); + + // 3. Setup Route and Stops + $area = ServiceArea::firstOrCreate( + ['tenant_id' => $tenantId, 'name' => 'Central District'], + [ + 'code' => 'GENSAN-CENTRAL', + 'status' => 'active' + ] + ); + + $route = Route::updateOrCreate( + ['name' => 'Morning Central Route'], + [ + 'tenant_id' => $tenantId, + 'area_id' => $area->id, + 'code' => 'GENSAN-MORNING-CENTRAL', + 'status' => 'active' + ] + ); + + // Create 5 Drop Off Points and Route Stops + for ($i = 1; $i <= 5; $i++) { + $dop = DropOffPoint::firstOrCreate( + ['name' => "GenSan Stop $i"], + [ + 'tenant_id' => $tenantId, + 'code' => "GENSAN-STOP-$i", + 'address_line' => "GenSan Street Address $i", + 'coordinates' => new Point(6.1136 + ($i * 0.001), 125.1719 + ($i * 0.001), 4326), + ] + ); + + RouteStop::updateOrCreate( + ['route_id' => $route->id, 'drop_off_point_id' => $dop->id], + ['sequence' => $i] + ); + } + + // 4. Create Trip for Team A + $tripA = Trip::updateOrCreate( + ['trip_number' => 'TRIP-' . now()->format('Ymd') . '-A1'], + [ + 'uuid' => Str::uuid(), + 'tenant_id' => $tenantId, + 'route_id' => $route->id, + 'team_id' => $teamA->id, + 'truck_id' => $truck->id, + 'scheduled_date' => now()->toDateString(), + 'scheduled_start_time' => '08:00:00', + 'actual_start_time' => now()->subHours(2), + 'status' => Trip::STATUS_IN_PROGRESS, + 'total_load_kg' => 5000, + 'current_load_kg' => 4800, // Almost full + ] + ); + + // Copy stops to Trip A + foreach ($route->stops as $rs) { + TripStop::updateOrCreate( + ['trip_id' => $tripA->id, 'sequence' => $rs->sequence], + [ + 'drop_off_point_id' => $rs->drop_off_point_id, + 'status' => $rs->sequence == 1 ? TripStop::STATUS_COMPLETED : TripStop::STATUS_PENDING, + 'actual_arrival' => $rs->sequence == 1 ? now()->subHours(1) : null, + 'actual_departure' => $rs->sequence == 1 ? now()->subMinutes(45) : null, + ] + ); + } + + // 5. Simulate 1 QR Scan for Stop 1 + $batch = QrCodeBatch::firstOrCreate( + ['tenant_id' => $tenantId, 'batch_number' => 'SIM-001'], + ['quantity' => 100, 'purpose' => QrCodeBatch::PURPOSE_FREE] + ); + + $qr = QrCode::updateOrCreate( + ['serial' => 'VERDE-SIM-001'], + [ + 'tenant_id' => $tenantId, + 'barcode_value' => 'VERDE-SIM-001', + 'batch_id' => $batch->id, + 'status' => \App\States\QrCode\Active::class, + ] + ); + + $household = Household::firstOrCreate( + ['tenant_id' => $tenantId, 'address_line' => 'GenSan Main St Simulated'], + [ + 'head_user_id' => $driverA->id, // Reusing driver as head for sim + 'household_size' => 4, + 'verification_status' => 'approved' + ] + ); + + $qr->update(['assigned_to_household_id' => $household->id]); + + CollectionLog::updateOrCreate( + ['trip_id' => $tripA->id, 'qr_code_id' => $qr->id], + [ + 'tenant_id' => $tenantId, + 'household_id' => $household->id, + 'drop_off_point_id' => $route->stops->first()->drop_off_point_id, + 'scanned_by_user_id' => $scannerA->id, + 'trip_stop_id' => $tripA->stops->first()->id, + 'scanned_at' => now()->subHours(1), + 'coordinates_at_scan' => new Point(6.1136, 125.1719, 4326), + 'weight_kg' => 50, + 'waste_type' => 'mixed', + 'verification_status' => CollectionLog::STATUS_VALID, + ] + ); + + // 6. Assign Team B to continue the route + $executor = app(TripExecutor::class); + $admin = User::where('role', User::ROLE_ADMIN)->where('tenant_id', $tenantId)->first() + ?? User::where('role', User::ROLE_SUPER_ADMIN)->first(); + + if ($admin) { + $executor->createContinuation( + originalTrip: $tripA, + admin: $admin, + newTeamId: $teamB->id, + newTruckId: $truck->id, // Use same truck for simulation or different + endReason: 'truck_full' + ); + } + + $this->command->info('Simulation Seeder Completed Successfully!'); + $this->command->info("Team A Trip (Full): {$tripA->trip_number}"); + $this->command->info("Team B Account: driver_b@verde.local / password1"); + } +} diff --git a/resources/js/admin.js b/resources/js/admin.js index a55137e..63ea717 100644 --- a/resources/js/admin.js +++ b/resources/js/admin.js @@ -166,6 +166,68 @@ function getEcho() { return _echo; } +function renderPagination(container, meta, onPageChange) { + if (!container) return; + if (!meta || meta.last_page <= 1) { + container.innerHTML = ''; + return; + } + + const { page, last_page, total } = meta; + + let html = ` +
+
+ + +
+ +
+ `; + + container.innerHTML = html; + container.querySelectorAll('.pagination-btn').forEach(btn => { + btn.addEventListener('click', () => { + const p = parseInt(btn.dataset.page, 10); + if (p && p !== page && p >= 1 && p <= last_page) { + onPageChange(p); + } + }); + }); +} + Object.assign(window.Verde, { apiFetch, requireAuth, @@ -177,4 +239,5 @@ Object.assign(window.Verde, { escapeHtml, formatDate, getEcho, + renderPagination, }); diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index 15eba5d..1b0bf67 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -1,10 +1,12 @@ @extends('layouts.web', ['title' => ($pageTitle ?? 'Admin') . ' — Verde']) @section('content') -
- @include('admin.partials.sidebar') +
+ -
+
{{-- Topbar --}}
@@ -23,7 +25,7 @@
-
+
@yield('page')
diff --git a/resources/views/admin/lgus.blade.php b/resources/views/admin/lgus.blade.php index 83b125b..b61f2a8 100644 --- a/resources/views/admin/lgus.blade.php +++ b/resources/views/admin/lgus.blade.php @@ -448,8 +448,18 @@ } }, polyline: false, - circle: false, - rectangle: false, + circle: { + shapeOptions: { + color: document.getElementById('theme-color-hex').value || '#16a34a', + weight: 3 + } + }, + rectangle: { + shapeOptions: { + color: document.getElementById('theme-color-hex').value || '#16a34a', + weight: 3 + } + }, marker: false, circlemarker: false }, @@ -480,9 +490,33 @@ } function updatePickerPointsFromLayer(layer) { - if (layer && layer.getLatLngs().length > 0) { - const latlngs = layer.getLatLngs()[0]; // Polygon has array of arrays - pickerPoints = latlngs.map(ll => window.L.latLng(ll.lat, ll.lng)); + if (!layer) { + pickerPoints = []; + return; + } + + const L = window.L; + + if (layer instanceof L.Circle) { + // High precision approximation for circle (128 vertices) + const center = layer.getLatLng(); + const radius = layer.getRadius(); + const points = []; + const segments = 128; + + for (let i = 0; i < segments; i++) { + const angle = (i * 2 * Math.PI) / segments; + // Approximation of degree offsets + const dx = (radius / 111320) * Math.cos(angle); + const dy = (radius / (111320 * Math.cos(center.lat * Math.PI / 180))) * Math.sin(angle); + points.push(L.latLng(center.lat + dx, center.lng + dy)); + } + pickerPoints = points; + } else if (layer.getLatLngs) { + const latlngs = layer.getLatLngs(); + // Handle multi-polygons or nested arrays + const pts = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs; + pickerPoints = pts.map(ll => L.latLng(ll.lat, ll.lng)); } else { pickerPoints = []; } @@ -677,7 +711,9 @@ } if (drawControl) { drawControl.setDrawingOptions({ - polygon: { shapeOptions: { color: color, weight: 3 } } + polygon: { shapeOptions: { color: color, weight: 3 } }, + circle: { shapeOptions: { color: color, weight: 3 } }, + rectangle: { shapeOptions: { color: color, weight: 3 } } }); } } diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index c72ec71..3c6c7da 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -66,47 +66,45 @@ $renderIcon = fn ($name) => $icons[$name] ?? $icons['cog']; @endphp - + @foreach ($groups as $groupName => $items) + + @endforeach + + @endsection diff --git a/resources/views/admin/teams.blade.php b/resources/views/admin/teams.blade.php index 975f493..da2f370 100644 --- a/resources/views/admin/teams.blade.php +++ b/resources/views/admin/teams.blade.php @@ -18,6 +18,16 @@ + +
+ + +
+
@@ -25,6 +35,8 @@
Loading…
+ +