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 = ` +
+ Showing page ${page} of ${last_page} + (${total} total results) +
+