diff --git a/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php b/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php index 9481e1f..37d533c 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php @@ -142,6 +142,11 @@ class AdminHouseholdController extends ApiController } } + if (! $tenantId) { + // Fall back to the first available Tenant in the database to prevent blocking super-admins + $tenantId = Tenant::value('id'); + } + if (! $tenantId) { return $this->fail('Could not resolve LGU/Tenant context for the selected Barangay.', null, 400); } @@ -264,9 +269,11 @@ class AdminHouseholdController extends ApiController $data = $request->validated(); if (! empty($data['user_id'])) { - $isMember = HouseholdMember::where('user_id', $data['user_id'])->exists(); - if ($isMember) { - return $this->fail('The selected resident already belongs to a household.', ['user_id' => ['already_member']], 422); + $isMemberOfCurrent = HouseholdMember::where('user_id', $data['user_id']) + ->where('household_id', $household->id) + ->exists(); + if ($isMemberOfCurrent) { + return $this->fail('The selected resident already belongs to this household.', ['user_id' => ['already_member_here']], 422); } $user = User::findOrFail($data['user_id']); @@ -282,22 +289,61 @@ class AdminHouseholdController extends ApiController } } - $member = HouseholdMember::create([ - 'household_id' => $household->id, - 'user_id' => $data['user_id'] ?? null, - 'relationship' => $data['relationship'], - 'full_name' => $fullName, - 'date_of_birth' => $data['date_of_birth'] ?? null, - ]); + $member = DB::transaction(function () use ($data, $household, $fullName) { + if (! empty($data['user_id'])) { + // Find all existing memberships of this user + $existingMemberships = HouseholdMember::where('user_id', $data['user_id'])->get(); - if ($data['relationship'] === HouseholdMember::RELATIONSHIP_HEAD && ! empty($data['user_id'])) { - $household->update(['head_user_id' => $data['user_id']]); - } + foreach ($existingMemberships as $oldMember) { + $oldHousehold = $oldMember->household; + if ($oldHousehold) { + // Delete the old membership + $oldMember->delete(); - $currentMemberCount = $household->members()->count(); - if ($household->household_size < $currentMemberCount) { - $household->update(['household_size' => $currentMemberCount]); - } + // If they were the head of that old household + if ($oldHousehold->head_user_id == $data['user_id']) { + $otherMembers = $oldHousehold->members()->where('user_id', '!=', $data['user_id'])->get(); + if ($otherMembers->isEmpty()) { + // Delete empty household + $oldHousehold->delete(); + } else { + // Reassign head to next available member + $nextHead = $otherMembers->first(); + $oldHousehold->update([ + 'head_user_id' => $nextHead->user_id, + ]); + $nextHead->update([ + 'relationship' => HouseholdMember::RELATIONSHIP_HEAD, + ]); + } + } else { + if ($oldHousehold->members()->count() === 0) { + $oldHousehold->delete(); + } + } + } + } + } + + $m = HouseholdMember::create([ + 'household_id' => $household->id, + 'user_id' => $data['user_id'] ?? null, + 'relationship' => $data['relationship'], + 'full_name' => $fullName, + 'date_of_birth' => $data['date_of_birth'] ?? null, + ]); + + if ($data['relationship'] === HouseholdMember::RELATIONSHIP_HEAD && ! empty($data['user_id'])) { + $household->update(['head_user_id' => $data['user_id']]); + } + + $currentMemberCount = $household->members()->count(); + if ($household->household_size < $currentMemberCount) { + $household->update(['household_size' => $currentMemberCount]); + } + + return $m; + }); $household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); diff --git a/app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php b/app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php index 0e107fa..51b61f5 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminQrBatchController.php @@ -28,7 +28,7 @@ class AdminQrBatchController extends ApiController { $request->validate([ 'purpose' => ['nullable', 'in:free_allocation,store_inventory,promotional'], - 'target_area_id' => ['nullable', 'integer'], + 'household_id' => ['nullable', 'integer'], 'q' => ['nullable', 'string', 'max:100'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], ]); @@ -36,12 +36,12 @@ class AdminQrBatchController extends ApiController $perPage = (int) $request->input('per_page', 25); $batches = QrCodeBatch::query() - ->with(['targetArea', 'createdBy']) + ->with(['household.head', 'createdBy']) ->withCount('codes') ->when($request->filled('purpose'), fn ($q) => $q->where('purpose', $request->string('purpose'))) ->when( - $request->filled('target_area_id'), - fn ($q) => $q->where('target_area_id', $request->integer('target_area_id')), + $request->filled('household_id'), + fn ($q) => $q->where('household_id', $request->integer('household_id')), ) ->when( $request->filled('q'), @@ -65,28 +65,27 @@ class AdminQrBatchController extends ApiController public function store(GenerateBatchRequest $request, BatchGenerator $generator): JsonResponse { $data = $request->validated(); - $area = isset($data['target_area_id']) - ? ServiceArea::where('uuid', $data['target_area_id'])->first() - : null; + $household = Household::where('uuid', $data['household_id'])->firstOrFail(); $batch = $generator->generate( quantity: (int) $data['quantity'], purpose: $data['purpose'], - targetArea: $area, + targetArea: null, targetStoreId: $data['target_store_id'] ?? null, createdBy: $request->user(), expiresAt: isset($data['expires_at']) ? Carbon::parse($data['expires_at']) : null, notes: $data['notes'] ?? null, + householdId: $household->id, ); - $batch->load(['targetArea', 'createdBy', 'codes']); + $batch->load(['household.head', 'createdBy', 'codes']); return $this->created(new QrCodeBatchResource($batch), 'Batch generated'); } public function show(QrCodeBatch $qrCodeBatch): JsonResponse { - $qrCodeBatch->load(['targetArea', 'createdBy', 'codes']); + $qrCodeBatch->load(['household.head', 'createdBy', 'codes']); return $this->ok(new QrCodeBatchResource($qrCodeBatch)); } diff --git a/app/Http/Controllers/Api/V1/Admin/AdminTeamController.php b/app/Http/Controllers/Api/V1/Admin/AdminTeamController.php index 99de8b0..4a6c2f3 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminTeamController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminTeamController.php @@ -7,6 +7,7 @@ use App\Http\Requests\Team\StoreCollectionTeamRequest; use App\Http\Resources\CollectionTeamResource; use App\Models\CollectionTeam; use App\Models\TeamMember; +use App\Models\User; use App\Services\Team\TeamConflictDetector; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -49,9 +50,20 @@ class AdminTeamController extends ApiController public function store(StoreCollectionTeamRequest $request): JsonResponse { $data = $request->validated(); - $helperIds = $data['helper_ids'] ?? []; $override = (bool) ($data['override_conflicts'] ?? false); - unset($data['helper_ids'], $data['override_conflicts']); + + if (! isset($data['helper_count']) && isset($data['helper_ids'])) { + $data['helper_count'] = count($data['helper_ids']); + $data['helper_assignment_mode'] = 'manual'; + } + + $helperIds = $this->resolveHelpers($data); + $expectedCount = (int) ($data['helper_count'] ?? 0); + if ($expectedCount > 0 && count($helperIds) < $expectedCount) { + return $this->fail('Not enough available helpers found.', [ + 'helper_count' => ['Only ' . count($helperIds) . ' available helper(s) found.'] + ], 422); + } if (! $override) { $conflicts = $this->conflicts->check( @@ -65,6 +77,8 @@ class AdminTeamController extends ApiController } } + unset($data['helper_ids'], $data['override_conflicts'], $data['helper_count'], $data['helper_assignment_mode']); + $team = DB::transaction(function () use ($data, $helperIds) { $team = CollectionTeam::create($data); @@ -99,16 +113,29 @@ class AdminTeamController extends ApiController public function update(StoreCollectionTeamRequest $request, CollectionTeam $team): JsonResponse { $data = $request->validated(); - $helperIds = $data['helper_ids'] ?? null; $override = (bool) ($data['override_conflicts'] ?? false); - unset($data['helper_ids'], $data['override_conflicts']); + + $helperIds = null; + if (array_key_exists('helper_count', $data) || array_key_exists('helper_ids', $data)) { + if (! isset($data['helper_count']) && isset($data['helper_ids'])) { + $data['helper_count'] = count($data['helper_ids']); + $data['helper_assignment_mode'] = 'manual'; + } + $helperIds = $this->resolveHelpers($data, $team->id); + $expectedCount = (int) ($data['helper_count'] ?? 0); + if ($expectedCount > 0 && count($helperIds) < $expectedCount) { + return $this->fail('Not enough available helpers found.', [ + 'helper_count' => ['Only ' . count($helperIds) . ' available helper(s) found.'] + ], 422); + } + } if (! $override) { $conflicts = $this->conflicts->check( $data['driver_id'] ?? $team->driver_id, $data['scanner_id'] ?? $team->scanner_id, $data['truck_id'] ?? $team->truck_id, - $helperIds ?? [], + $helperIds ?? $team->helpers()->pluck('user_id')->toArray(), $team->id, ); if (! empty($conflicts)) { @@ -116,6 +143,8 @@ class AdminTeamController extends ApiController } } + unset($data['helper_ids'], $data['override_conflicts'], $data['helper_count'], $data['helper_assignment_mode']); + DB::transaction(function () use ($team, $data, $helperIds) { $team->update($data); @@ -141,6 +170,40 @@ class AdminTeamController extends ApiController ); } + protected function resolveHelpers(array $data, ?int $excludeTeamId = null): array + { + $mode = $data['helper_assignment_mode'] ?? 'manual'; + $count = (int) ($data['helper_count'] ?? 0); + + if ($count <= 0) { + return []; + } + + if ($mode === 'manual') { + return $data['helper_ids'] ?? []; + } + + $override = (bool) ($data['override_conflicts'] ?? false); + $query = User::where('role', User::ROLE_HELPER)->where('status', User::STATUS_ACTIVE); + + if (! $override) { + $today = now()->toDateString(); + $assignedHelperIds = TeamMember::query() + ->where('role_in_team', TeamMember::ROLE_HELPER) + ->whereDate('assigned_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('assigned_until')->orWhereDate('assigned_until', '>=', $today); + }) + ->when($excludeTeamId, fn ($q, $id) => $q->where('team_id', '!=', $id)) + ->pluck('user_id') + ->toArray(); + + $query->whereNotIn('id', $assignedHelperIds); + } + + return $query->inRandomOrder()->take($count)->pluck('id')->toArray(); + } + public function destroy(CollectionTeam $team): JsonResponse { $team->delete(); diff --git a/app/Http/Controllers/Api/V1/Admin/AdminUserController.php b/app/Http/Controllers/Api/V1/Admin/AdminUserController.php index 0683d15..cd60323 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminUserController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminUserController.php @@ -6,10 +6,14 @@ use App\Http\Controllers\Api\V1\ApiController; use App\Http\Requests\Admin\RejectProfileRequest; use App\Http\Requests\Admin\UpdateUserRequest; use App\Http\Resources\UserDetailResource; +use App\Http\Resources\HouseholdResource; use App\Models\User; +use App\Models\Household; +use App\Models\HouseholdMember; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; +use Illuminate\Support\Facades\DB; class AdminUserController extends ApiController { @@ -110,6 +114,19 @@ class AdminUserController extends ApiController public function activate(User $user): JsonResponse { + if ($user->role === User::ROLE_RESIDENT) { + if (! auth()->user()->isSuperAdmin()) { + return $this->forbidden('Only Super Admins can activate or approve resident profiles'); + } + + $householdMember = \App\Models\HouseholdMember::where('user_id', $user->id)->first(); + $household = $householdMember ? $householdMember->household : $user->headedHousehold; + + if (! $household || ! $household->coordinates) { + return $this->fail('Resident household must have coordinates pinned before activation', null, 422); + } + } + $user->forceFill(['status' => User::STATUS_ACTIVE])->save(); return $this->ok(new UserDetailResource($user->fresh()), 'User activated'); @@ -134,6 +151,19 @@ class AdminUserController extends ApiController return $this->fail('User has no verifiable profile', null, 422); } + if ($user->role === User::ROLE_RESIDENT) { + if (! auth()->user()->isSuperAdmin()) { + return $this->forbidden('Only Super Admins can activate or approve resident profiles'); + } + + $householdMember = \App\Models\HouseholdMember::where('user_id', $user->id)->first(); + $household = $householdMember ? $householdMember->household : $user->headedHousehold; + + if (! $household || ! $household->coordinates) { + return $this->fail('Resident household must have coordinates pinned before activation', null, 422); + } + } + $profile->markVerified($request->user()); $user->forceFill(['status' => User::STATUS_ACTIVE])->save(); @@ -152,4 +182,113 @@ class AdminUserController extends ApiController return $this->ok(new UserDetailResource($user->fresh()->load($user->profileRelation())), 'Profile rejected'); } + + public function groupIntoHousehold(Request $request, User $user): JsonResponse + { + if ($user->role !== User::ROLE_RESIDENT) { + return $this->fail('Only Resident users can be grouped into households', null, 422); + } + + $data = $request->validate([ + 'household_id' => ['nullable', 'integer', 'exists:households,id'], + // If household_id is null, require new household details: + 'address_line' => ['required_without:household_id', 'nullable', 'string', 'max:255'], + 'barangay_id' => ['required_without:household_id', 'nullable', 'integer', 'exists:barangays,id'], + 'lat' => ['required_without:household_id', 'nullable', 'numeric', 'between:-90,90'], + 'lng' => ['required_without:household_id', 'nullable', 'numeric', 'between:-180,180'], + 'household_size' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + + if (! empty($data['household_id'])) { + $isMemberOfCurrent = HouseholdMember::where('user_id', $user->id) + ->where('household_id', $data['household_id']) + ->exists(); + if ($isMemberOfCurrent) { + return $this->fail('Resident already belongs to this household.', null, 422); + } + } + + $household = DB::transaction(function () use ($data, $user) { + // Find all existing memberships of this user + $existingMemberships = HouseholdMember::where('user_id', $user->id)->get(); + + foreach ($existingMemberships as $oldMember) { + $oldHousehold = $oldMember->household; + if ($oldHousehold) { + // Delete the old membership + $oldMember->delete(); + + // If they were the head of that old household + if ($oldHousehold->head_user_id == $user->id) { + $otherMembers = $oldHousehold->members()->where('user_id', '!=', $user->id)->get(); + if ($otherMembers->isEmpty()) { + // Delete empty household + $oldHousehold->delete(); + } else { + // Reassign head to next available member + $nextHead = $otherMembers->first(); + $oldHousehold->update([ + 'head_user_id' => $nextHead->user_id, + ]); + $nextHead->update([ + 'relationship' => HouseholdMember::RELATIONSHIP_HEAD, + ]); + } + } else { + if ($oldHousehold->members()->count() === 0) { + $oldHousehold->delete(); + } + } + } + } + + if (! empty($data['household_id'])) { + $h = Household::findOrFail($data['household_id']); + HouseholdMember::create([ + 'household_id' => $h->id, + 'user_id' => $user->id, + 'relationship' => HouseholdMember::RELATIONSHIP_OTHER, + 'full_name' => $user->full_name, + ]); + + $currentMemberCount = $h->members()->count(); + if ($h->household_size < $currentMemberCount) { + $h->update(['household_size' => $currentMemberCount]); + } + + return $h; + } else { + $point = new \MatanYadaev\EloquentSpatial\Objects\Point((float) $data['lat'], (float) $data['lng'], 4326); + $dopFinder = app(\App\Services\DropOff\DropOffPointFinder::class); + $nearestDop = $dopFinder->nearest((float) $data['lat'], (float) $data['lng']); + + $h = Household::create([ + 'tenant_id' => $user->tenant_id, + 'head_user_id' => $user->id, + 'barangay_id' => $data['barangay_id'], + 'address_line' => $data['address_line'], + 'coordinates' => $point, + 'household_size' => $data['household_size'] ?? 1, + 'assigned_drop_off_point_id' => $nearestDop?->id, + 'verification_status' => Household::VERIFICATION_PENDING, + ]); + + HouseholdMember::create([ + 'household_id' => $h->id, + 'user_id' => $user->id, + 'relationship' => HouseholdMember::RELATIONSHIP_HEAD, + 'full_name' => $user->full_name, + ]); + + return $h; + } + }); + + $user->load(['residentProfile', 'driverProfile', 'helperProfile', 'scannerProfile', 'storePartnerProfile']); + + return $this->ok([ + 'user' => new UserDetailResource($user), + 'household' => new HouseholdResource($household->load(['head', 'barangay'])), + ], 'User successfully grouped into household'); + } } diff --git a/app/Http/Controllers/Api/V1/Auth/RegisterController.php b/app/Http/Controllers/Api/V1/Auth/RegisterController.php index 3477149..234413a 100644 --- a/app/Http/Controllers/Api/V1/Auth/RegisterController.php +++ b/app/Http/Controllers/Api/V1/Auth/RegisterController.php @@ -8,15 +8,19 @@ use App\Http\Resources\UserResource; use App\Models\NotificationPreference; use App\Models\OtpCode; use App\Models\User; +use App\Models\Household; +use App\Models\HouseholdMember; use App\Services\Otp\OtpService; +use App\Services\DropOff\DropOffPointFinder; use App\Tenancy\Tenancy; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use MatanYadaev\EloquentSpatial\Objects\Point; class RegisterController extends ApiController { - public function __invoke(RegisterRequest $request, OtpService $otp): JsonResponse + public function __invoke(RegisterRequest $request, OtpService $otp, DropOffPointFinder $dopFinder): JsonResponse { $data = $request->validated(); @@ -27,7 +31,7 @@ class RegisterController extends ApiController return $this->fail('Pick your LGU first.', ['tenant' => ['LGU code required']], 400); } - $user = DB::transaction(function () use ($data, $tenant) { + $user = DB::transaction(function () use ($data, $tenant, $dopFinder) { $user = User::create([ 'tenant_id' => $tenant->id, 'first_name' => $data['first_name'], @@ -53,6 +57,29 @@ class RegisterController extends ApiController 'language' => $data['preferred_language'] ?? 'en', ]); + if ($data['role'] === User::ROLE_RESIDENT) { + $point = new Point((float) $data['lat'], (float) $data['lng'], 4326); + $nearestDop = $dopFinder->nearest((float) $data['lat'], (float) $data['lng']); + + $household = Household::create([ + 'tenant_id' => $tenant->id, + 'head_user_id' => $user->id, + 'barangay_id' => $data['barangay_id'], + 'address_line' => $data['address_line'], + 'coordinates' => $point, + 'household_size' => $data['household_size'] ?? 1, + 'assigned_drop_off_point_id' => $nearestDop?->id, + 'verification_status' => Household::VERIFICATION_PENDING, + ]); + + HouseholdMember::create([ + 'household_id' => $household->id, + 'user_id' => $user->id, + 'relationship' => HouseholdMember::RELATIONSHIP_HEAD, + 'full_name' => $user->full_name, + ]); + } + return $user; }); diff --git a/app/Http/Controllers/Api/V1/Driver/DriverTripController.php b/app/Http/Controllers/Api/V1/Driver/DriverTripController.php index 58ece8f..504cc0c 100644 --- a/app/Http/Controllers/Api/V1/Driver/DriverTripController.php +++ b/app/Http/Controllers/Api/V1/Driver/DriverTripController.php @@ -30,7 +30,7 @@ class DriverTripController extends ApiController public function show(Request $request, Trip $trip): JsonResponse { $this->authorizeDriver($request, $trip); - $trip->load(['route', 'team', 'truck', 'dumpsite', 'stops.dropOffPoint', 'timelineEvents.recordedBy']); + $trip->load(['route', 'team.driver', 'team.scanner', 'team.helpers.user', 'truck', 'dumpsite', 'stops.dropOffPoint', 'timelineEvents.recordedBy']); return $this->ok(new TripResource($trip)); } diff --git a/app/Http/Controllers/Api/V1/Helper/HelperAssignmentController.php b/app/Http/Controllers/Api/V1/Helper/HelperAssignmentController.php new file mode 100644 index 0000000..4c00c52 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Helper/HelperAssignmentController.php @@ -0,0 +1,97 @@ +user(); + $today = now()->toDateString(); + + $assignment = TeamMember::query() + ->where('user_id', $user->id) + ->where('role_in_team', TeamMember::ROLE_HELPER) + ->whereDate('assigned_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('assigned_until')->orWhereDate('assigned_until', '>=', $today); + }) + ->with(['team.driver', 'team.scanner', 'team.truck', 'team.area']) + ->orderBy('id', 'desc') + ->first(); + + if (! $assignment) { + return $this->ok(null, 'No active assignment for today.'); + } + + return $this->ok(new TeamMemberResource($assignment)); + } + + public function accept(Request $request): JsonResponse + { + $user = $request->user(); + $today = now()->toDateString(); + + $assignment = TeamMember::query() + ->where('user_id', $user->id) + ->where('role_in_team', TeamMember::ROLE_HELPER) + ->whereDate('assigned_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('assigned_until')->orWhereDate('assigned_until', '>=', $today); + }) + ->orderBy('id', 'desc') + ->first(); + + if (! $assignment) { + return $this->fail('No active assignment to accept.', null, 404); + } + + $assignment->update(['status' => TeamMember::STATUS_ACCEPTED]); + + return $this->ok(new TeamMemberResource($assignment->load(['team.driver', 'team.scanner', 'team.truck', 'team.area'])), 'Assignment accepted.'); + } + + public function reject(Request $request): JsonResponse + { + $user = $request->user(); + $today = now()->toDateString(); + + $assignment = TeamMember::query() + ->where('user_id', $user->id) + ->where('role_in_team', TeamMember::ROLE_HELPER) + ->whereDate('assigned_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('assigned_until')->orWhereDate('assigned_until', '>=', $today); + }) + ->orderBy('id', 'desc') + ->first(); + + if (! $assignment) { + return $this->fail('No active assignment to reject.', null, 404); + } + + $assignment->update(['status' => TeamMember::STATUS_REJECTED]); + + return $this->ok(new TeamMemberResource($assignment->load(['team.driver', 'team.scanner', 'team.truck', 'team.area'])), 'Assignment rejected.'); + } + + public function history(Request $request): JsonResponse + { + $user = $request->user(); + + $assignments = TeamMember::query() + ->where('user_id', $user->id) + ->where('role_in_team', TeamMember::ROLE_HELPER) + ->with(['team.driver', 'team.scanner', 'team.truck', 'team.area']) + ->orderBy('assigned_from', 'desc') + ->paginate($request->integer('per_page', 15)); + + return $this->ok(TeamMemberResource::collection($assignments)); + } +} diff --git a/app/Http/Controllers/Api/V1/Scanner/ScannerController.php b/app/Http/Controllers/Api/V1/Scanner/ScannerController.php index 4ac899f..5901c02 100644 --- a/app/Http/Controllers/Api/V1/Scanner/ScannerController.php +++ b/app/Http/Controllers/Api/V1/Scanner/ScannerController.php @@ -15,6 +15,33 @@ class ScannerController extends ApiController { public function __construct(private readonly ScanService $scanner) {} + public function teamDetails(Request $request): JsonResponse + { + $user = $request->user(); + + $team = \App\Models\CollectionTeam::query() + ->with(['driver', 'truck', 'helpers.user']) + ->where('scanner_id', $user->id) + ->where('status', \App\Models\CollectionTeam::STATUS_ACTIVE) + ->first(); + + if (! $team) { + return $this->fail('No active collection team assigned to this scanner', null, 404); + } + + $trip = \App\Models\Trip::query() + ->with(['route.stops.dropOffPoint', 'stops.dropOffPoint']) + ->where('team_id', $team->id) + ->whereIn('status', [\App\Models\Trip::STATUS_SCHEDULED, \App\Models\Trip::STATUS_IN_PROGRESS, \App\Models\Trip::STATUS_AT_DUMPSITE]) + ->orderBy('scheduled_date') + ->first(); + + return $this->ok([ + 'team' => \App\Http\Resources\CollectionTeamResource::make($team), + 'active_trip' => $trip ? \App\Http\Resources\TripResource::make($trip) : null, + ]); + } + public function scan(Request $request): JsonResponse { $data = $request->validate([ diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php index 2ee28c3..b8095c5 100644 --- a/app/Http/Requests/Auth/RegisterRequest.php +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -31,6 +31,11 @@ class RegisterRequest extends FormRequest User::ROLE_STORE_PARTNER, ])], 'preferred_language' => ['nullable', 'string', 'in:en,tl,ceb'], + 'address_line' => [Rule::requiredIf($this->input('role') === User::ROLE_RESIDENT), 'nullable', 'string', 'max:255'], + 'barangay_id' => [Rule::requiredIf($this->input('role') === User::ROLE_RESIDENT), 'nullable', 'integer', 'exists:barangays,id'], + 'lat' => [Rule::requiredIf($this->input('role') === User::ROLE_RESIDENT), 'nullable', 'numeric', 'between:-90,90'], + 'lng' => [Rule::requiredIf($this->input('role') === User::ROLE_RESIDENT), 'nullable', 'numeric', 'between:-180,180'], + 'household_size' => [Rule::requiredIf($this->input('role') === User::ROLE_RESIDENT), 'nullable', 'integer', 'min:1', 'max:50'], ]; } } diff --git a/app/Http/Requests/Qr/GenerateBatchRequest.php b/app/Http/Requests/Qr/GenerateBatchRequest.php index 4876a60..e2a8217 100644 --- a/app/Http/Requests/Qr/GenerateBatchRequest.php +++ b/app/Http/Requests/Qr/GenerateBatchRequest.php @@ -22,7 +22,7 @@ class GenerateBatchRequest extends FormRequest QrCodeBatch::PURPOSE_STORE, QrCodeBatch::PURPOSE_PROMO, ])], - 'target_area_id' => ['nullable', 'string', 'exists:service_areas,uuid'], + 'household_id' => ['required', 'string', 'exists:households,uuid'], 'target_store_id' => ['nullable', 'integer'], 'expires_at' => ['nullable', 'date', 'after:today'], 'notes' => ['nullable', 'string', 'max:1000'], diff --git a/app/Http/Requests/Team/StoreCollectionTeamRequest.php b/app/Http/Requests/Team/StoreCollectionTeamRequest.php index bf80e9b..01692a3 100644 --- a/app/Http/Requests/Team/StoreCollectionTeamRequest.php +++ b/app/Http/Requests/Team/StoreCollectionTeamRequest.php @@ -23,6 +23,8 @@ class StoreCollectionTeamRequest extends FormRequest 'truck_id' => ['nullable', 'integer', 'exists:trucks,id'], 'helper_ids' => ['nullable', 'array'], 'helper_ids.*' => ['integer', 'exists:users,id'], + 'helper_count' => ['nullable', 'integer', 'min:0', 'max:10'], + 'helper_assignment_mode' => ['nullable', 'string', Rule::in(['random', 'manual'])], 'status' => ['nullable', Rule::in([CollectionTeam::STATUS_ACTIVE, CollectionTeam::STATUS_INACTIVE])], 'notes' => ['nullable', 'string', 'max:1000'], 'override_conflicts' => ['nullable', 'boolean'], diff --git a/app/Http/Resources/CollectionTeamResource.php b/app/Http/Resources/CollectionTeamResource.php index cd833c2..222667e 100644 --- a/app/Http/Resources/CollectionTeamResource.php +++ b/app/Http/Resources/CollectionTeamResource.php @@ -23,6 +23,7 @@ class CollectionTeamResource extends JsonResource 'name' => $m->user?->full_name, 'assigned_from' => $m->assigned_from?->toDateString(), 'assigned_until' => $m->assigned_until?->toDateString(), + 'status' => $m->status, ])), 'notes' => $this->notes, 'created_at' => $this->created_at?->toIso8601String(), diff --git a/app/Http/Resources/QrCodeBatchResource.php b/app/Http/Resources/QrCodeBatchResource.php index 989a2f4..c442ea8 100644 --- a/app/Http/Resources/QrCodeBatchResource.php +++ b/app/Http/Resources/QrCodeBatchResource.php @@ -22,6 +22,11 @@ class QrCodeBatchResource extends JsonResource 'purpose' => $this->purpose, 'target_area' => $this->whenLoaded('targetArea', fn () => $this->targetArea?->code), 'target_store_id' => $this->target_store_id, + 'household' => $this->whenLoaded('household', fn () => $this->household ? [ + 'id' => $this->household->id, + 'address' => $this->household->address_line, + 'head_name' => $this->household->head?->full_name, + ] : null), 'created_by' => $this->whenLoaded('createdBy', fn () => $this->createdBy?->full_name), 'printed_at' => $this->printed_at?->toIso8601String(), 'expires_at' => $this->expires_at?->toIso8601String(), diff --git a/app/Http/Resources/TeamMemberResource.php b/app/Http/Resources/TeamMemberResource.php new file mode 100644 index 0000000..fe9cf4f --- /dev/null +++ b/app/Http/Resources/TeamMemberResource.php @@ -0,0 +1,23 @@ + $this->id, + 'role_in_team' => $this->role_in_team, + 'status' => $this->status, + 'assigned_from' => $this->assigned_from?->toDateString(), + 'assigned_until' => $this->assigned_until?->toDateString(), + 'team' => CollectionTeamResource::make($this->whenLoaded('team')), + 'user' => UserResource::make($this->whenLoaded('user')), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/UserDetailResource.php b/app/Http/Resources/UserDetailResource.php index efa418d..96b0e47 100644 --- a/app/Http/Resources/UserDetailResource.php +++ b/app/Http/Resources/UserDetailResource.php @@ -30,6 +30,7 @@ class UserDetailResource extends JsonResource 'last_login_at' => $this->last_login_at?->toIso8601String(), 'created_at' => $this->created_at?->toIso8601String(), 'profile' => $profile?->toArray(), + 'has_household' => $this->headedHousehold()->exists() || $this->householdMemberships()->exists(), ]; } } diff --git a/app/Models/QrCodeBatch.php b/app/Models/QrCodeBatch.php index 6acea3a..45d4a0c 100644 --- a/app/Models/QrCodeBatch.php +++ b/app/Models/QrCodeBatch.php @@ -28,6 +28,7 @@ class QrCodeBatch extends Model 'purpose', 'target_area_id', 'target_store_id', + 'household_id', 'created_by_admin_id', 'printed_at', 'expires_at', @@ -53,6 +54,11 @@ class QrCodeBatch extends Model return $this->belongsTo(ServiceArea::class, 'target_area_id'); } + public function household(): BelongsTo + { + return $this->belongsTo(Household::class, 'household_id'); + } + public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by_admin_id'); diff --git a/app/Models/TeamMember.php b/app/Models/TeamMember.php index 5e907b8..3c5907e 100644 --- a/app/Models/TeamMember.php +++ b/app/Models/TeamMember.php @@ -18,9 +18,15 @@ class TeamMember extends Model public const ROLE_LEAD = 'lead'; + public const STATUS_PENDING = 'pending'; + + public const STATUS_ACCEPTED = 'accepted'; + + public const STATUS_REJECTED = 'rejected'; + protected $fillable = [ 'team_id', 'user_id', 'role_in_team', - 'assigned_from', 'assigned_until', + 'assigned_from', 'assigned_until', 'status', ]; protected function casts(): array diff --git a/app/Services/Qr/BatchGenerator.php b/app/Services/Qr/BatchGenerator.php index 999901e..87fe74f 100644 --- a/app/Services/Qr/BatchGenerator.php +++ b/app/Services/Qr/BatchGenerator.php @@ -7,6 +7,7 @@ use App\Models\QrCodeBatch; use App\Models\ServiceArea; use App\Models\User; use App\States\QrCode\Unassigned; +use App\States\QrCode\Active; use App\Tenancy\Tenancy; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -26,6 +27,7 @@ class BatchGenerator ?User $createdBy = null, ?\DateTimeInterface $expiresAt = null, ?string $notes = null, + ?int $householdId = null, ): QrCodeBatch { $now = Carbon::now(); $areaCode = $targetArea?->code ?? 'PH'; @@ -34,7 +36,7 @@ class BatchGenerator return DB::transaction(function () use ( $batchNumber, $quantity, $purpose, $targetArea, $targetStoreId, - $createdBy, $expiresAt, $notes, $now, $areaCode, $batchSeq, + $createdBy, $expiresAt, $notes, $now, $areaCode, $batchSeq, $householdId, ) { $batch = QrCodeBatch::create([ 'batch_number' => $batchNumber, @@ -42,6 +44,7 @@ class BatchGenerator 'purpose' => $purpose, 'target_area_id' => $targetArea?->id, 'target_store_id' => $targetStoreId, + 'household_id' => $householdId, 'created_by_admin_id' => $createdBy?->id, 'expires_at' => $expiresAt, 'notes' => $notes, @@ -61,7 +64,10 @@ class BatchGenerator 'serial' => $serial, 'barcode_value' => $serial, 'batch_id' => $batch->id, - 'status' => Unassigned::$name, + 'status' => $householdId ? Active::$name : Unassigned::$name, + 'assigned_to_household_id' => $householdId, + 'allocated_at' => $householdId ? $tsNow : null, + 'activated_at' => $householdId ? $tsNow : null, 'expires_at' => $expiresAt, 'created_at' => $tsNow, 'updated_at' => $tsNow, diff --git a/database/migrations/2026_06_29_080410_add_household_id_to_qr_code_batches_table.php b/database/migrations/2026_06_29_080410_add_household_id_to_qr_code_batches_table.php new file mode 100644 index 0000000..b3866b9 --- /dev/null +++ b/database/migrations/2026_06_29_080410_add_household_id_to_qr_code_batches_table.php @@ -0,0 +1,33 @@ +foreignId('household_id') + ->nullable() + ->after('target_store_id') + ->constrained('households') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('qr_code_batches', function (Blueprint $table) { + $table->dropForeign(['household_id']); + $table->dropColumn('household_id'); + }); + } +}; diff --git a/database/migrations/2026_06_29_085121_add_status_to_team_members_table.php b/database/migrations/2026_06_29_085121_add_status_to_team_members_table.php new file mode 100644 index 0000000..40f8b73 --- /dev/null +++ b/database/migrations/2026_06_29_085121_add_status_to_team_members_table.php @@ -0,0 +1,28 @@ +string('status')->default('pending')->after('assigned_until'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('team_members', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } +}; diff --git a/resources/views/admin/households.blade.php b/resources/views/admin/households.blade.php index 40d3bd8..06eebba 100644 --- a/resources/views/admin/households.blade.php +++ b/resources/views/admin/households.blade.php @@ -242,6 +242,7 @@ let editingHouseholdId = null; let editingMemberId = null; let availableResidentsForMember = []; + let currentHousehold = null; function statusBadge(s) { const map = { pending: 'badge-pending', approved: 'badge-active', rejected: 'badge-rejected' }; @@ -459,7 +460,7 @@ select.innerHTML = ''; } else { select.innerHTML = '' + - list.map(u => ``).join(''); + list.map(u => ``).join(''); } } else { select.innerHTML = ''; @@ -595,14 +596,31 @@ async function loadAvailableResidentsForMember() { memberUserId.innerHTML = ''; - const res = await window.Verde.apiFetch('/api/v1/admin/users?role=resident&without_household=1'); + const res = await window.Verde.apiFetch('/api/v1/admin/users?role=resident'); if (res.ok) { availableResidentsForMember = res.body.data ?? []; - if (availableResidentsForMember.length === 0) { - memberUserId.innerHTML = ''; + + // Filter out residents who are already members or head of this household + const existingMemberUserIds = []; + if (currentHousehold && currentHousehold.members) { + currentHousehold.members.forEach(m => { + if (m.user_id) existingMemberUserIds.push(parseInt(m.user_id, 10)); + }); + } + if (currentHousehold && currentHousehold.head && currentHousehold.head.db_id) { + existingMemberUserIds.push(parseInt(currentHousehold.head.db_id, 10)); + } + + const filteredResidents = availableResidentsForMember.filter(u => { + const uId = parseInt(u.db_id || u.id, 10); + return !existingMemberUserIds.includes(uId); + }); + + if (filteredResidents.length === 0) { + memberUserId.innerHTML = ''; } else { memberUserId.innerHTML = '' + - availableResidentsForMember.map(u => ``).join(''); + filteredResidents.map(u => ``).join(''); } } else { memberUserId.innerHTML = ''; @@ -777,7 +795,8 @@ } const h = res.body.data; - + currentHousehold = h; + createForm.querySelector('[name="address_line"]').value = h.address_line; createForm.querySelector('[name="household_size"]').value = h.household_size; diff --git a/resources/views/admin/qr-batches.blade.php b/resources/views/admin/qr-batches.blade.php index a5225db..481cdfd 100644 --- a/resources/views/admin/qr-batches.blade.php +++ b/resources/views/admin/qr-batches.blade.php @@ -1,13 +1,16 @@ -@extends('admin.layouts.app', ['pageTitle' => 'QR Code Batches']) +@extends('admin.layouts.app', ['pageTitle' => 'QR Code Sales']) @section('page') + + +
Generate, print, and audit QR code lifecycle.
+Sell QR codes directly to verified households and audit code distribution.