From 21874c0764016541ff7f98727ca185214ac569d8 Mon Sep 17 00:00:00 2001 From: ramram1515 Date: Fri, 3 Jul 2026 11:54:40 +0800 Subject: [PATCH] Fix store sales search and QR wallet receipt downloads --- activeTruckPositions() | 22 ++ activeTruckPositions()) | 22 ++ .../Api/V1/Me/MySalesController.php | 24 +- .../Api/V1/Qr/MyQrCodeController.php | 32 +- .../Api/V1/Store/StorePortalController.php | 153 ++++++++- app/Http/Resources/QrCodeBatchResource.php | 6 + app/Models/User.php | 14 + app/Policies/StoreSalePolicy.php | 12 + app/Services/LiveTracking/TruckTracker.php | 38 ++- config/sanctum.php | 2 +- database/seeders/DevelopmentSeeder.php | 297 ++++++++++++------ resources/js/admin.js | 8 + resources/views/admin/qr-batches.blade.php | 2 +- .../views/store/partials/sidebar.blade.php | 11 +- resources/views/store/sales.blade.php | 8 +- routes/api.php | 21 +- trucks() | 22 ++ 17 files changed, 532 insertions(+), 162 deletions(-) create mode 100644 activeTruckPositions() create mode 100644 activeTruckPositions()) create mode 100644 trucks() diff --git a/activeTruckPositions() b/activeTruckPositions() new file mode 100644 index 0000000..af3fd68 --- /dev/null +++ b/activeTruckPositions() @@ -0,0 +1,22 @@ + + InvalidArgumentException + + Unexpected end of input + + at vendor\psy\psysh\src\Shell.php:1475 + 1471▕ + 1472▕ if (!$this->hasValidCode()) { + 1473▕ $this->popCodeStack(); + 1474▕ + ➜ 1475▕ throw new \InvalidArgumentException('Unexpected end of input'); + 1476▕ } + 1477▕ } + 1478▕ + 1479▕ /** + + 1 vendor\psy\psysh\src\Shell.php:2173 + Psy\Shell::setCode("app(App\Services\LiveTracking\TruckTracker::class)-") + + 2 vendor\laravel\tinker\src\Console\TinkerCommand.php:77 + Psy\Shell::execute("app(App\Services\LiveTracking\TruckTracker::class)-") + diff --git a/activeTruckPositions()) b/activeTruckPositions()) new file mode 100644 index 0000000..c45c4f4 --- /dev/null +++ b/activeTruckPositions()) @@ -0,0 +1,22 @@ + + Psy\Exception\ParseErrorException + + PHP Parse error: Syntax error, unexpected ';' on line 1 + + at vendor\psy\psysh\src\Exception\ParseErrorException.php:44 + 40▕ * @param \PhpParser\Error $e + 41▕ */ + 42▕ public static function fromParseError(\PhpParser\Error $e): self + 43▕ { + ➜ 44▕ return new self($e->getRawMessage(), $e->getAttributes()); + 45▕ } + 46▕ } + 47▕ + + 1 vendor\psy\psysh\src\CodeCleaner.php:657 + Psy\Exception\ParseErrorException::fromParseError(Object(PhpParser\Error)) + + 2 vendor\psy\psysh\src\CodeCleaner.php:273 + Psy\CodeCleaner::parse("getHousehold(); - if (! $household) { - return $this->ok([], 'No household associated with this account.'); - } - - $sales = StoreSale::where('household_id', $household->id) + $sales = StoreSale::where(function ($q) use ($household) { + if ($household) { + $q->where('household_id', $household->id); + } + $q->orWhere('user_id', auth()->id()); + }) ->with('store') ->latest('sold_at') ->paginate(15); @@ -46,7 +49,11 @@ class MySalesController extends ApiController 'quantity' => $s->quantity, 'retail_price_pesos' => number_format($s->retail_price_centavos / 100, 2), 'sold_at' => $s->sold_at?->toIso8601String() ?? $s->created_at->toIso8601String(), - 'receipt_url' => route('api.v1.me.sales.receipt', $s->id), + 'receipt_url' => URL::temporarySignedRoute( + 'api.v1.me.sales.receipt', + now()->addHours(24), + ['sale' => $s->id] + ), ]), null, [ @@ -62,11 +69,6 @@ class MySalesController extends ApiController */ public function downloadReceipt(StoreSale $sale): Response { - $household = $this->getHousehold(); - - if (! $household || $sale->household_id !== $household->id) { - abort(403, 'Unauthorized access to this receipt.'); - } $sale->load(['store', 'household.head', 'household.barangay']); diff --git a/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php b/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php index fd1cf13..e9e58f0 100644 --- a/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php +++ b/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php @@ -65,6 +65,8 @@ class MyQrCodeController extends ApiController $threshold = (int) config('qr.low_balance_threshold', 5); return $this->ok([ + 'balance' => $active, + 'household_name' => $household->name ?? "{$household->code} Household", 'active' => $active, 'allocated' => (int) ($counts['allocated'] ?? 0), 'used' => (int) ($counts['used'] ?? 0), @@ -74,7 +76,7 @@ class MyQrCodeController extends ApiController ]); } - public function activate(Request $request, QrCode $qrCode, QrAllocator $allocator): JsonResponse + public function activate(Request $request, QrAllocator $allocator, ?string $serial = null): JsonResponse { $request->validate([ 'activate_batch' => ['nullable', 'boolean'], @@ -88,17 +90,35 @@ class MyQrCodeController extends ApiController $activateBatch = $request->boolean('activate_batch'); if ($activateBatch) { - // Activate the whole batch - if (! $qrCode->batch_id) { - return $this->fail('This QR code does not belong to a batch', null, 422); + // Find the first allocated code to trigger batch activation + $qrCode = QrCode::where('assigned_to_household_id', $household->id) + ->where('status', Allocated::$name) + ->first(); + + if (! $qrCode) { + return $this->fail('No unactivated codes found for your household', null, 422); } $activatedCount = $allocator->activateBatch($qrCode, $household); if ($activatedCount === 0) { - return $this->fail('No unactivated codes found in this batch, or you do not have permission to activate this batch', null, 422); + return $this->fail('No unactivated codes found in this batch', null, 422); } - return $this->ok(['activated_count' => $activatedCount], "Successfully activated $activatedCount QR codes in the batch"); + return $this->ok(['activated_count' => $activatedCount], "Successfully activated $activatedCount QR codes"); + } + + // Single code activation - serial is required + if (! $serial) { + return $this->fail('Serial number is required for single code activation', null, 422); + } + + $qrCode = QrCode::where('serial', $serial)->first(); + if (! $qrCode) { + return $this->fail('QR code not found', null, 404); + } + + if ($qrCode->assigned_to_household_id !== $household->id) { + return $this->forbidden('This code is not assigned to your household'); } // Single code activation diff --git a/app/Http/Controllers/Api/V1/Store/StorePortalController.php b/app/Http/Controllers/Api/V1/Store/StorePortalController.php index 3b5211b..fe1444d 100644 --- a/app/Http/Controllers/Api/V1/Store/StorePortalController.php +++ b/app/Http/Controllers/Api/V1/Store/StorePortalController.php @@ -12,10 +12,16 @@ use App\Models\User; use App\Services\Store\StoreOperations; use App\States\QrCode\Active; use Barryvdh\DomPDF\Facade\Pdf; +use Endroid\QrCode\Builder\Builder; +use Endroid\QrCode\ErrorCorrectionLevel; use Endroid\QrCode\Writer\PngWriter; +use Endroid\QrCode\Writer\SvgWriter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\URL; +use Picqer\Barcode\BarcodeGeneratorPNG; +use Picqer\Barcode\BarcodeGeneratorSVG; class StorePortalController extends ApiController { @@ -141,7 +147,19 @@ class StorePortalController extends ApiController }); return $this->created([ - 'sale' => $sale, + 'sale' => [ + 'id' => $sale->id, + 'receipt_url' => URL::temporarySignedRoute( + 'api.v1.store.sales.receipt', + now()->addHours(24), + ['sale' => $sale->id] + ), + 'codes_url' => URL::temporarySignedRoute( + 'api.v1.store.sales.codes', + now()->addHours(24), + ['sale' => $sale->id] + ), + ], 'codes' => $qrData, ], 'Sale recorded successfully.'); } catch (\Exception $e) { @@ -161,6 +179,27 @@ class StorePortalController extends ApiController ->latest() ->paginate(15); + $sales->getCollection()->transform(fn ($s) => [ + 'id' => $s->id, + 'household' => $s->household ? [ + 'head_name' => $s->household->head?->full_name, + 'address' => $s->household->address_line, + ] : null, + 'quantity' => $s->quantity, + 'retail_price_pesos' => $s->retail_price_centavos / 100, + 'sold_at' => $s->sold_at->toIso8601String(), + 'receipt_url' => URL::temporarySignedRoute( + 'api.v1.store.sales.receipt', + now()->addHours(24), + ['sale' => $s->id] + ), + 'codes_url' => URL::temporarySignedRoute( + 'api.v1.store.sales.codes', + now()->addHours(24), + ['sale' => $s->id] + ), + ]); + return $this->ok($sales); } @@ -182,7 +221,6 @@ class StorePortalController extends ApiController public function downloadReceipt(StoreSale $sale): Response { - $this->authorize('view', $sale); $sale->load(['store', 'household.head', 'household.barangay']); @@ -193,6 +231,102 @@ class StorePortalController extends ApiController return $pdf->download("receipt-{$sale->id}.pdf"); } + /** + * Download the actual QR code stickers for a specific sale. + */ + public function downloadQrCodes(StoreSale $sale): Response + { + + // Find the QR codes that were activated in this sale. + // Since StoreSale doesn't have a direct link to QrCodes, + // we look for codes assigned to this household/user and activated around the same time. + $query = QrCode::query() + ->where('status', Active::$name) + ->where('activated_at', '>=', $sale->sold_at->subMinutes(5)) + ->where('activated_at', '<=', $sale->sold_at->addMinutes(5)); + + if ($sale->household_id) { + $query->where('assigned_to_household_id', $sale->household_id); + } else { + $query->where('assigned_to_user_id', $sale->user_id); + } + + $codes = $query->limit($sale->quantity)->get(); + + if ($codes->isEmpty()) { + abort(404, 'No QR codes found for this sale.'); + } + + $rendered = $codes->map(function ($c) { + return [ + 'serial' => $c->serial, + 'qr_data_uri' => $this->qrDataUri($c->serial), + 'barcode_data_uri' => $this->barcodeDataUri($c->serial), + ]; + })->all(); + + $perPage = 24; + $pages = array_chunk($rendered, $perPage); + + // We reuse the admin PDF view for consistency + $pdf = Pdf::loadView('pdf.qr-batch', [ + 'batch' => (object)[ + 'batch_number' => "SALE-{$sale->id}", + 'purpose' => 'Retail Sale', + 'quantity' => $sale->quantity + ], + 'pages' => $pages, + ])->setPaper('a4'); + + return $pdf->download("verde-codes-sale-{$sale->id}.pdf"); + } + + private function qrDataUri(string $serial): string + { + try { + $result = (new Builder( + writer: new PngWriter, + data: $serial, + errorCorrectionLevel: ErrorCorrectionLevel::Medium, + size: 140, + margin: 4, + ))->build(); + + return $result->getDataUri(); + } catch (\Throwable $e) { + if (class_exists(SvgWriter::class)) { + $result = (new Builder( + writer: new SvgWriter, + data: $serial, + errorCorrectionLevel: ErrorCorrectionLevel::Medium, + size: 140, + margin: 4, + ))->build(); + + return 'data:image/svg+xml;base64,'.base64_encode($result->getString()); + } + throw $e; + } + } + + private function barcodeDataUri(string $serial): string + { + try { + $generator = new BarcodeGeneratorPNG; + $png = $generator->getBarcode($serial, BarcodeGeneratorPNG::TYPE_CODE_128, 1, 28); + + return 'data:image/png;base64,'.base64_encode($png); + } catch (\Throwable $e) { + if (class_exists(BarcodeGeneratorSVG::class)) { + $generator = new BarcodeGeneratorSVG; + $svg = $generator->getBarcode($serial, BarcodeGeneratorSVG::TYPE_CODE_128, 1, 28); + + return 'data:image/svg+xml;base64,'.base64_encode($svg); + } + throw $e; + } + } + /** * Search for households to record a sale. Scoped to the store's LGU. */ @@ -210,15 +344,16 @@ class StorePortalController extends ApiController ->with(['head', 'barangay']) ->where('tenant_id', $store->tenant_id) ->where('verification_status', Household::VERIFICATION_APPROVED) - ->where(function ($q) use ($term) { + ->where(function ($q) use ($term, $request) { + // Remove the '%' wrappers for exact word processing + $rawTerm = $request->string('q'); + $q->where('address_line', 'like', $term) - ->orWhereHas('head', function ($h) use ($term) { - $h->where('first_name', 'like', $term) - ->orWhere('last_name', 'like', $term); + ->orWhereHas('head', function ($h) use ($rawTerm, $term) { + $h->whereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", [$term]); }) - ->orWhereHas('members', function ($m) use ($term) { - $m->where('first_name', 'like', $term) - ->orWhere('last_name', 'like', $term); + ->orWhereHas('members', function ($m) use ($rawTerm, $term) { + $m->whereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", [$term]); }); }) ->limit(15) diff --git a/app/Http/Resources/QrCodeBatchResource.php b/app/Http/Resources/QrCodeBatchResource.php index d262a29..673f617 100644 --- a/app/Http/Resources/QrCodeBatchResource.php +++ b/app/Http/Resources/QrCodeBatchResource.php @@ -5,6 +5,7 @@ namespace App\Http\Resources; use App\Models\QrCode; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\URL; class QrCodeBatchResource extends JsonResource { @@ -39,6 +40,11 @@ class QrCodeBatchResource extends JsonResource 'expires_at' => $this->expires_at?->toIso8601String(), 'notes' => $this->notes, 'status_counts' => $statusCounts, + 'print_url' => URL::temporarySignedRoute( + 'api.v1.admin.qr-batches.print-pdf', + now()->addHours(24), + ['qr_code_batch' => $this->id] + ), 'created_at' => $this->created_at?->toIso8601String(), ]; } diff --git a/app/Models/User.php b/app/Models/User.php index 20513b9..72d54bc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; +use App\Models\Household; +use App\Models\HouseholdMember; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; @@ -191,4 +193,16 @@ class User extends Authenticatable implements MustVerifyEmail return $relation ? $this->{$relation} : null; } + + /** + * Get the household this user belongs to (either as head or member). + */ + public function getHouseholdAttribute(): ?Household + { + return Household::where('head_user_id', $this->id) + ->orWhereHas('members', function ($q) { + $q->where('user_id', $this->id); + }) + ->first(); + } } diff --git a/app/Policies/StoreSalePolicy.php b/app/Policies/StoreSalePolicy.php index 6daa01a..33e8aa1 100644 --- a/app/Policies/StoreSalePolicy.php +++ b/app/Policies/StoreSalePolicy.php @@ -28,6 +28,18 @@ class StoreSalePolicy return $user->id === $storeSale->store?->owner_user_id; } + if ($user->role === User::ROLE_RESIDENT) { + // Either the individual buyer + if ($user->id === $storeSale->user_id) { + return true; + } + + // Or part of the household + if ($storeSale->household_id) { + return $user->household?->id === $storeSale->household_id; + } + } + return false; } diff --git a/app/Services/LiveTracking/TruckTracker.php b/app/Services/LiveTracking/TruckTracker.php index 9ec7204..7675a54 100644 --- a/app/Services/LiveTracking/TruckTracker.php +++ b/app/Services/LiveTracking/TruckTracker.php @@ -94,30 +94,40 @@ class TruckTracker */ public function activeTruckPositions(): array { - $trucks = Cache::remember('active_truck_positions_list', now()->addSeconds(15), function () { + $truckData = Cache::remember('active_truck_positions_list', now()->addSeconds(15), function () { return Truck::query() ->where('status', Truck::STATUS_ACTIVE) ->whereNotNull('last_known_coordinates') ->where('last_location_updated_at', '>=', now()->subHour()) ->with('assignedTeam') - ->get(); + ->get() + ->map(fn (Truck $t) => [ + 'id' => $t->id, + 'uuid' => $t->uuid, + 'plate_number' => $t->plate_number, + 'team_name' => $t->assignedTeam?->name, + 'lat' => $t->last_known_coordinates->latitude, + 'lng' => $t->last_known_coordinates->longitude, + 'last_location_updated_at' => $t->last_location_updated_at?->toIso8601String(), + ]) + ->all(); }); - return $trucks->map(function (Truck $t) { - $cached = Cache::get($this->cacheKey($t)); + return array_map(function (array $t) { + $cached = Cache::get("truck:position:{$t['id']}"); return [ - 'truck_id' => $t->uuid, - 'plate_number' => $t->plate_number, - 'team' => $t->assignedTeam?->name, - 'lat' => $t->last_known_coordinates->latitude, - 'lng' => $t->last_known_coordinates->longitude, - 'heading_degrees' => $cached['heading_degrees'] ?? null, - 'speed_kmh' => $cached['speed_kmh'] ?? null, - 'recorded_at' => $t->last_location_updated_at?->toIso8601String(), - 'active_trip_id' => $cached['trip_id'] ?? null, + 'truck_id' => $t['uuid'], + 'plate_number' => $t['plate_number'], + 'team' => $t['team_name'], + 'lat' => $t['lat'], + 'lng' => $t['lng'], + 'heading_degrees' => is_array($cached) ? ($cached['heading_degrees'] ?? null) : null, + 'speed_kmh' => is_array($cached) ? ($cached['speed_kmh'] ?? null) : null, + 'recorded_at' => $t['last_location_updated_at'], + 'active_trip_id' => is_array($cached) ? ($cached['trip_id'] ?? null) : null, ]; - })->all(); + }, $truckData); } private function cacheKey(Truck $truck): string diff --git a/config/sanctum.php b/config/sanctum.php index cde73cf..7acdd7a 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -20,7 +20,7 @@ return [ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1,192.168.254.171,192.168.254.171:8000', Sanctum::currentApplicationUrlWithPort(), // Sanctum::currentRequestHost(), ))), diff --git a/database/seeders/DevelopmentSeeder.php b/database/seeders/DevelopmentSeeder.php index d4abd14..792cb28 100644 --- a/database/seeders/DevelopmentSeeder.php +++ b/database/seeders/DevelopmentSeeder.php @@ -17,12 +17,17 @@ use App\Models\RouteStop; use App\Models\ScannerProfile; use App\Models\StoreInventory; use App\Models\StorePartnerProfile; +use App\Models\StoreSale; use App\Models\TeamMember; use App\Models\Tenant; use App\Models\Trip; use App\Models\TripStop; use App\Models\Truck; use App\Models\User; +use App\Models\QrCode; +use App\Models\QrCodeBatch; +use App\States\QrCode\Active; +use App\States\QrCode\Allocated; use App\Tenancy\Tenancy; use Carbon\Carbon; use Illuminate\Database\Seeder; @@ -32,8 +37,12 @@ use MatanYadaev\EloquentSpatial\Objects\Point; class DevelopmentSeeder extends Seeder { + public function __construct(private readonly \App\Services\Qr\BatchGenerator $generator) {} + public function run(): void { + $this->call(SamplePsgcSeeder::class); + $this->call(RoleSeeder::class); $password = Hash::make('password1'); // 1. Exactly 1 Super Admin @@ -52,26 +61,17 @@ class DevelopmentSeeder extends Seeder )->syncRoles(['super_admin']); // 2. Three LGUs (Tenants) - $lguConfigs = [ - ['code' => 'SPB', 'name' => 'San Pascual, Batangas'], - ['code' => 'BAU', 'name' => 'Bauan, Batangas'], - ['code' => 'LEM', 'name' => 'Lemery, Batangas'], - ]; + // 1. Quezon City (Primary testing LGU) + $tenantQC = Tenant::updateOrCreate(['code' => 'QC'], ['name' => 'Quezon City', 'status' => Tenant::STATUS_ACTIVE, 'qr_retail_price_centavos' => 1000]); + $this->seedTenantData($tenantQC, $password, 0); - foreach ($lguConfigs as $index => $config) { - $tenant = Tenant::firstOrCreate( - ['code' => $config['code']], - [ - 'name' => $config['name'], - 'status' => Tenant::STATUS_ACTIVE, - 'qr_retail_price_centavos' => 1000, - 'uuid' => (string) Str::uuid(), - ] - ); - - Tenancy::setCurrent($tenant); - $this->seedTenantData($tenant, $password, $index); - } + // 2. Manila + $tenantMNL = Tenant::updateOrCreate(['code' => 'MNL'], ['name' => 'Manila', 'status' => Tenant::STATUS_ACTIVE, 'qr_retail_price_centavos' => 1000]); + $this->seedTenantData($tenantMNL, $password, 1); + + // 3. Pasig + $tenantPSG = Tenant::updateOrCreate(['code' => 'PSG'], ['name' => 'Pasig', 'status' => Tenant::STATUS_ACTIVE, 'qr_retail_price_centavos' => 1000]); + $this->seedTenantData($tenantPSG, $password, 2); } private function seedTenantData(Tenant $tenant, string $password, int $lguIndex): void @@ -83,27 +83,31 @@ class DevelopmentSeeder extends Seeder $dumpsites = []; for ($i = 1; $i <= 3; $i++) { - $dumpsites[] = Dumpsite::create([ - 'name' => "{$tenant->code} Landfill {$i}", - 'uuid' => (string) Str::uuid(), - 'tenant_id' => $tenant->id, - 'code' => "DS-{$tenant->code}-00{$i}", - 'address_line' => "Zone {$i}", - 'coordinates' => new Point(14.67 + ($i * 0.01), 121.08, 4326), - ]); + $dumpsites[] = Dumpsite::updateOrCreate( + ['code' => "DS-{$tenant->code}-00{$i}"], + [ + 'name' => "{$tenant->code} Landfill {$i}", + 'uuid' => (string) Str::uuid(), + 'tenant_id' => $tenant->id, + 'address_line' => "Zone {$i}", + 'coordinates' => new Point(14.67 + ($i * 0.01), 121.08, 4326), + ] + ); } $dops = []; for ($i = 1; $i <= 5; $i++) { - $dops[] = DropOffPoint::create([ - 'name' => "{$tenant->code} Plaza {$i}", - 'uuid' => (string) Str::uuid(), - 'tenant_id' => $tenant->id, - 'code' => "DOP-{$tenant->code}-00{$i}", - 'barangay_id' => $barangay->id, - 'address_line' => "Point {$i}", - 'coordinates' => new Point(14.65 + ($i * 0.01), 121.06, 4326), - ]); + $dops[] = DropOffPoint::updateOrCreate( + ['code' => "DOP-{$tenant->code}-00{$i}"], + [ + 'name' => "{$tenant->code} Plaza {$i}", + 'uuid' => (string) Str::uuid(), + 'tenant_id' => $tenant->id, + 'barangay_id' => $barangay->id, + 'address_line' => "Point {$i}", + 'coordinates' => new Point(14.65 + ($i * 0.01), 121.06, 4326), + ] + ); } // 2. Staff & Residents (10 each) @@ -114,70 +118,155 @@ class DevelopmentSeeder extends Seeder // 3. Households (10 per LGU) foreach ($residents as $i => $resident) { - ResidentProfile::create(['user_id' => $resident->id]); - Household::create([ - 'uuid' => (string) Str::uuid(), - 'tenant_id' => $tenant->id, - 'address_line' => "Street {$i}", - 'barangay_id' => $barangay->id, - 'head_user_id' => $resident->id, - 'assigned_drop_off_point_id' => $dops[rand(0, 4)]->id, - 'verification_status' => 'approved', - 'verified_at' => now(), - ]); + ResidentProfile::firstOrCreate(['user_id' => $resident->id]); + Household::updateOrCreate( + ['head_user_id' => $resident->id], + [ + 'uuid' => (string) Str::uuid(), + 'tenant_id' => $tenant->id, + 'address_line' => "Street {$i}", + 'barangay_id' => $barangay->id, + 'assigned_drop_off_point_id' => $dops[rand(0, 4)]->id, + 'verification_status' => 'approved', + 'verified_at' => now(), + ] + ); } // 4. Store Admins (5 per LGU) for ($i = 1; $i <= 5; $i++) { $owner = $this->createStaff("store{$i}.{$tenant->code}@verde.local", "+63921{$lguIndex}000{$i}", "Store", "Owner {$i}", User::ROLE_STORE_PARTNER, $tenant, $password); - StorePartnerProfile::create(['user_id' => $owner->id, 'business_name' => "Store {$i} - {$tenant->code}", 'verification_status' => 'approved', 'verified_at' => now()]); - $store = PartnerStore::create([ - 'owner_user_id' => $owner->id, - 'uuid' => (string) Str::uuid(), - 'tenant_id' => $tenant->id, - 'business_name' => "Store {$i} - {$tenant->code}", - 'business_permit_number' => "BP-{$tenant->code}-{$i}", - 'address_line' => "Market St {$i}", - 'barangay_id' => $barangay->id, - 'coordinates' => new Point(14.651 + ($i * 0.001), 121.061, 4326), - 'status' => PartnerStore::STATUS_ACTIVE, - ]); - StoreInventory::create(['store_id' => $store->id, 'current_code_balance' => 500]); + StorePartnerProfile::updateOrCreate(['user_id' => $owner->id], ['business_name' => "Store {$i} - {$tenant->code}", 'verification_status' => 'approved', 'verified_at' => now()]); + $store = PartnerStore::updateOrCreate( + ['owner_user_id' => $owner->id], + [ + 'uuid' => (string) Str::uuid(), + 'tenant_id' => $tenant->id, + 'business_name' => "Store {$i} - {$tenant->code}", + 'business_permit_number' => "BP-{$tenant->code}-{$i}", + 'address_line' => "Market St {$i}", + 'barangay_id' => $barangay->id, + 'coordinates' => new Point(14.651 + ($i * 0.001), 121.061, 4326), + 'status' => PartnerStore::STATUS_ACTIVE, + ] + ); + $inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], ['current_code_balance' => 500, 'prepaid_balance' => 0]); + + // Seed actual QR codes for the store only if they don't have any + if ($store->qrCodes()->count() === 0) { + $this->generator->generate( + quantity: 500, + purpose: QrCodeBatch::PURPOSE_STORE, + targetStoreId: $store->id, + notes: "Initial stock for {$store->business_name}" + ); + + // Assign allocated codes to the store + QrCode::whereIn('batch_id', QrCodeBatch::where('target_store_id', $store->id)->pluck('id')) + ->update([ + 'status' => Allocated::$name, + 'assigned_to_store_id' => $store->id, + 'allocated_at' => now(), + ]); + } } - // 5. Operations (5 Trucks, 5 Teams, 5 Routes, mixed Trips) + // 5. Some Initial Sales for testing + foreach ($residents as $i => $resident) { + $household = Household::where('head_user_id', $resident->id)->first(); + $store = PartnerStore::where('tenant_id', $tenant->id)->first(); + + if ($household && $store && StoreSale::where('household_id', $household->id)->count() === 0) { + // Record a sale for 10 codes + StoreSale::create([ + 'store_id' => $store->id, + 'household_id' => $household->id, + 'user_id' => $resident->id, + 'quantity' => 10, + 'retail_price_centavos' => 10000, + 'sold_at' => now(), + ]); + + // 5 codes are active + $codes = QrCode::where('assigned_to_store_id', $store->id) + ->where('status', Allocated::$name) + ->limit(5) + ->get(); + + foreach($codes as $code) { + $code->update([ + 'status' => Active::$name, + 'assigned_to_household_id' => $household->id, + 'assigned_to_store_id' => null, + 'activated_at' => now(), + ]); + } + + // 5 codes are allocated (purchased but not yet active) + $allocatedCodes = QrCode::where('assigned_to_store_id', $store->id) + ->where('status', Allocated::$name) + ->limit(5) + ->get(); + + foreach($allocatedCodes as $code) { + $code->update([ + 'status' => Allocated::$name, + 'assigned_to_household_id' => $household->id, + 'assigned_to_store_id' => null, + 'allocated_at' => now(), + ]); + } + + // Update store inventory + $store->inventory()->decrement('current_code_balance', 10); + } + } + + // 6. Operations (5 Trucks, 5 Teams, 5 Routes, mixed Trips) for ($i = 0; $i < 5; $i++) { - $truck = Truck::create([ - 'plate_number' => "VRD-{$tenant->code}-{$i}", - 'uuid' => (string) Str::uuid(), - 'tenant_id' => $tenant->id, - 'capacity_kg' => 3000, - 'status' => Truck::STATUS_ACTIVE, - ]); + $truck = Truck::updateOrCreate( + ['plate_number' => "VRD-{$tenant->code}-{$i}"], + [ + 'uuid' => (string) Str::uuid(), + 'tenant_id' => $tenant->id, + 'capacity_kg' => 3000, + 'status' => Truck::STATUS_ACTIVE, + ] + ); - $team = CollectionTeam::create([ - 'name' => "{$tenant->code} Team " . ($i + 1), - 'uuid' => (string) Str::uuid(), - 'tenant_id' => $tenant->id, - 'driver_id' => $drivers[$i]->id, - 'scanner_id' => $scanners[$i]->id, - 'truck_id' => $truck->id, - 'status' => CollectionTeam::STATUS_ACTIVE, - ]); - TeamMember::create(['team_id' => $team->id, 'user_id' => $helpers[$i]->id, 'role_in_team' => 'helper', 'assigned_from' => now()]); + $team = CollectionTeam::updateOrCreate( + ['name' => "{$tenant->code} Team " . ($i + 1)], + [ + 'uuid' => (string) Str::uuid(), + 'tenant_id' => $tenant->id, + 'driver_id' => $drivers[$i]->id, + 'scanner_id' => $scanners[$i]->id, + 'truck_id' => $truck->id, + 'status' => CollectionTeam::STATUS_ACTIVE, + ] + ); + TeamMember::firstOrCreate(['team_id' => $team->id, 'user_id' => $helpers[$i]->id], ['role_in_team' => 'helper', 'assigned_from' => now()]); - $route = CollectionRoute::create([ - 'uuid' => (string) Str::uuid(), - 'tenant_id' => $tenant->id, - 'code' => "RT-{$tenant->code}-{$i}", - 'name' => "{$tenant->code} Route " . ($i + 1), - 'default_dumpsite_id' => $dumpsites[rand(0, 2)]->id, - 'default_team_id' => $team->id, - 'status' => CollectionRoute::STATUS_ACTIVE, - ]); - RouteStop::create(['route_id' => $route->id, 'drop_off_point_id' => $dops[rand(0, 4)]->id, 'sequence' => 1]); + $route = CollectionRoute::updateOrCreate( + ['name' => "Route {$tenant->code}-" . ($i + 1)], + [ + 'uuid' => (string) Str::uuid(), + 'tenant_id' => $tenant->id, + 'code' => "RT-{$tenant->code}-{$i}", + 'default_team_id' => $team->id, + 'status' => 'active', + ] + ); - $this->seedTrips($tenant, $route, $team, $truck, $dumpsites[rand(0, 2)], $dops); + if ($route->stops()->count() === 0) { + foreach ($dops as $idx => $dop) { + RouteStop::create(['route_id' => $route->id, 'drop_off_point_id' => $dop->id, 'sequence' => $idx + 1]); + } + } + + if ($team->trips()->count() === 0) { + $this->seedTrips($tenant, $route, $team, $truck, $dumpsites[0], $dops); + } } } @@ -194,9 +283,9 @@ class DevelopmentSeeder extends Seeder for ($i = 1; $i <= $count; $i++) { $user = $this->createStaff("{$role}{$i}.{$tenant->code}@verde.local", "+63{$prefix}{$lguIndex}000{$i}", ucfirst($role), "{$i} ({$tenant->code})", "{$role}", $tenant, $password); - if ($role === 'driver') DriverProfile::create(['user_id' => $user->id, 'license_number' => "L-{$tenant->code}-{$i}", 'verification_status' => 'approved', 'verified_at' => now()]); - if ($role === 'scanner') ScannerProfile::create(['user_id' => $user->id, 'verification_status' => 'approved', 'verified_at' => now()]); - if ($role === 'helper') HelperProfile::create(['user_id' => $user->id, 'verification_status' => 'approved', 'verified_at' => now()]); + if ($role === 'driver') DriverProfile::updateOrCreate(['user_id' => $user->id], ['license_number' => "L-{$tenant->code}-{$i}", 'verification_status' => 'approved', 'verified_at' => now()]); + if ($role === 'scanner') ScannerProfile::updateOrCreate(['user_id' => $user->id], ['verification_status' => 'approved', 'verified_at' => now()]); + if ($role === 'helper') HelperProfile::updateOrCreate(['user_id' => $user->id], ['verification_status' => 'approved', 'verified_at' => now()]); $users[] = $user; } @@ -242,17 +331,19 @@ class DevelopmentSeeder extends Seeder private function createStaff($email, $phone, $first, $last, $role, $tenant, $password): User { - $u = User::create([ - 'email' => $email, - 'phone' => $phone, - 'password' => $password, - 'first_name' => $first, - 'last_name' => $last, - 'role' => $role, - 'status' => User::STATUS_ACTIVE, - 'tenant_id' => $tenant ? $tenant->id : null, - 'email_verified_at' => now(), - ]); + $u = User::updateOrCreate( + ['email' => $email], + [ + 'phone' => $phone, + 'password' => $password, + 'first_name' => $first, + 'last_name' => $last, + 'role' => $role, + 'status' => User::STATUS_ACTIVE, + 'tenant_id' => $tenant ? $tenant->id : null, + 'email_verified_at' => now(), + ] + ); $u->syncRoles([$role]); NotificationPreference::firstOrCreate(['user_id' => $u->id]); return $u; diff --git a/resources/js/admin.js b/resources/js/admin.js index a55137e..d7a0806 100644 --- a/resources/js/admin.js +++ b/resources/js/admin.js @@ -44,6 +44,14 @@ async function apiFetch(path, opts = {}) { 'X-Requested-With': 'XMLHttpRequest', ...(opts.headers || {}), }; + + const user = getUser(); + if (user?.tenant?.code) { + headers['X-Tenant-Code'] = user.tenant.code; + } else if (user?.tenant_code) { + headers['X-Tenant-Code'] = user.tenant_code; + } + if (token) headers['Authorization'] = `Bearer ${token}`; if (!isFormData && method !== 'GET' && method !== 'HEAD' && opts.body !== undefined) { headers['Content-Type'] = headers['Content-Type'] || 'application/json'; diff --git a/resources/views/admin/qr-batches.blade.php b/resources/views/admin/qr-batches.blade.php index 08d74e1..f9ab8aa 100644 --- a/resources/views/admin/qr-batches.blade.php +++ b/resources/views/admin/qr-batches.blade.php @@ -169,7 +169,7 @@ ${sc.used ?? 0} ${printed} - PDF + PDF ${!b.printed_at ? `` : ''} diff --git a/resources/views/store/partials/sidebar.blade.php b/resources/views/store/partials/sidebar.blade.php index 0593ad9..434f9c6 100644 --- a/resources/views/store/partials/sidebar.blade.php +++ b/resources/views/store/partials/sidebar.blade.php @@ -58,14 +58,9 @@