feat: integrate dynamic geofence fetching, service area spatial boundaries, partner store profiles & editing with Leaflet maps, custom store portal icons, QR distribution charts with geographic/LGU filters, and optimize admin dashboards

This commit is contained in:
Super Admin
2026-07-04 18:30:46 +08:00
parent 8d82674e8f
commit a2cf8c2c75
44 changed files with 3854 additions and 99 deletions

View File

@@ -36,6 +36,7 @@ class AdminHouseholdController extends ApiController
$request->validate([
'verification_status' => ['nullable', 'in:pending,approved,rejected'],
'barangay_id' => ['nullable', 'integer'],
'service_area_id' => ['nullable', 'integer'],
'q' => ['nullable', 'string', 'max:100'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:500'],
]);
@@ -43,7 +44,7 @@ class AdminHouseholdController extends ApiController
$perPage = (int) $request->input('per_page', 25);
$households = Household::query()
->with(['head', 'barangay'])
->with(['head', 'barangay.serviceAreas'])
->withCount('members')
->when(
$request->filled('verification_status'),
@@ -53,6 +54,10 @@ class AdminHouseholdController extends ApiController
$request->filled('barangay_id'),
fn ($q) => $q->where('barangay_id', $request->integer('barangay_id')),
)
->when(
$request->filled('service_area_id'),
fn ($q) => $q->whereHas('barangay.serviceAreas', fn ($sa) => $sa->where('service_areas.id', $request->integer('service_area_id'))),
)
->when($request->filled('q'), function ($q) use ($request) {
$term = '%'.$request->string('q').'%';
$q->where(function ($qq) use ($term) {
@@ -81,7 +86,7 @@ class AdminHouseholdController extends ApiController
public function show(Household $household): JsonResponse
{
$household->load(['head', 'barangay.cityMunicipality.province', 'members.user'])
$household->load(['head', 'barangay.cityMunicipality.province', 'barangay.serviceAreas', 'members.user'])
->loadCount('members');
return $this->ok(new HouseholdResource($household));
@@ -104,7 +109,7 @@ class AdminHouseholdController extends ApiController
$household->markVerified($request->user());
HouseholdVerified::dispatch($household->fresh(), $request->user());
$household->load(['head', 'barangay'])->loadCount('members');
$household->load(['head', 'barangay.serviceAreas'])->loadCount('members');
return $this->ok(new HouseholdResource($household), 'Household approved');
}
@@ -121,7 +126,7 @@ class AdminHouseholdController extends ApiController
);
}
$household->load(['head', 'barangay'])->loadCount('members');
$household->load(['head', 'barangay.serviceAreas'])->loadCount('members');
return $this->ok(new HouseholdResource($household), 'Household rejected');
}
@@ -225,7 +230,7 @@ class AdminHouseholdController extends ApiController
return $h;
});
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
return $this->created(
new HouseholdResource($household),
@@ -276,7 +281,7 @@ class AdminHouseholdController extends ApiController
->update(['user_id' => $newHeadId]);
}
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
return $this->ok(new HouseholdResource($household), 'Household updated successfully.');
}
@@ -300,7 +305,7 @@ class AdminHouseholdController extends ApiController
'assigned_drop_off_point_id' => $request->integer('drop_off_point_id'),
]);
$household->load(['head', 'barangay', 'assignedDropOffPoint'])->loadCount('members');
$household->load(['head', 'barangay.serviceAreas', 'assignedDropOffPoint'])->loadCount('members');
return $this->ok(new HouseholdResource($household), 'Drop-off point updated.');
}
@@ -406,7 +411,7 @@ class AdminHouseholdController extends ApiController
return $m;
});
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
return $this->created(new HouseholdResource($household), 'Household member added successfully.');
}
@@ -447,7 +452,7 @@ class AdminHouseholdController extends ApiController
'date_of_birth' => $data['date_of_birth'] ?? null,
]);
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
return $this->ok(new HouseholdResource($household), 'Household member updated successfully.');
}
@@ -464,7 +469,7 @@ class AdminHouseholdController extends ApiController
$member->delete();
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
return $this->ok(new HouseholdResource($household), 'Household member removed successfully.');
}

View File

@@ -8,6 +8,8 @@ use App\Models\Household;
use App\Models\PartnerStore;
use App\Models\QrCode;
use App\Models\StoreInventoryAdjustment;
use App\Models\StoreSale;
use App\Models\User;
use App\Services\Store\StoreOperations;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -146,9 +148,10 @@ class AdminPartnerStoreController extends ApiController
try {
$household = Household::findOrFail($data['household_id']);
$sale = $this->ops->sellToHousehold(
$sale = $this->ops->sell(
$store,
$household,
null,
(int) $data['quantity'],
$retailPrice,
);
@@ -370,4 +373,173 @@ class AdminPartnerStoreController extends ApiController
'total_sold_codes' => (int) $store->sales()->sum('quantity'),
]);
}
public function distributionChart(Request $request, PartnerStore $store): JsonResponse
{
$period = $request->input('period', 'daily');
$driver = \DB::getDriverName();
$isSqlite = $driver === 'sqlite';
$query = $store->sales();
switch ($period) {
case 'weekly':
$dateExpression = $isSqlite
? "strftime('%Y-W%W', COALESCE(sold_at, created_at))"
: "DATE_FORMAT(COALESCE(sold_at, created_at), '%x-W%v')";
$rows = $query
->selectRaw("{$dateExpression} as label, SUM(quantity) as total")
->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subWeeks(12)->startOfWeek()])
->groupByRaw($dateExpression)
->orderByRaw($dateExpression)
->get();
break;
case 'monthly':
$dateExpression = $isSqlite
? "strftime('%Y-%m', COALESCE(sold_at, created_at))"
: "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')";
$rows = $query
->selectRaw("{$dateExpression} as label, SUM(quantity) as total")
->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subMonths(12)->startOfMonth()])
->groupByRaw($dateExpression)
->orderByRaw($dateExpression)
->get();
break;
case 'overall':
$dateExpression = $isSqlite
? "strftime('%Y-%m', COALESCE(sold_at, created_at))"
: "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')";
$rows = $query
->selectRaw("{$dateExpression} as label, SUM(quantity) as total")
->groupByRaw($dateExpression)
->orderByRaw($dateExpression)
->get();
break;
default: // daily — last 30 days
$dateExpression = $isSqlite
? "date(COALESCE(sold_at, created_at))"
: "DATE(COALESCE(sold_at, created_at))";
$rows = $query
->selectRaw("{$dateExpression} as label, SUM(quantity) as total")
->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subDays(29)->startOfDay()])
->groupByRaw($dateExpression)
->orderByRaw($dateExpression)
->get();
break;
}
return $this->ok([
'period' => $period,
'labels' => $rows->pluck('label'),
'values' => $rows->pluck('total')->map(fn ($v) => (int) $v),
'total_distributed' => (int) $store->sales()->sum('quantity'),
]);
}
public function overallDistributionChart(Request $request): JsonResponse
{
$period = $request->input('period', 'daily');
$user = $request->user();
$isSuperAdmin = $user && $user->role === User::ROLE_SUPER_ADMIN;
$storeQuery = PartnerStore::query();
if ($isSuperAdmin) {
if ($request->filled('tenant_id')) {
$storeQuery->withoutGlobalScopes()->where('tenant_id', $request->integer('tenant_id'));
} else {
$storeQuery->withoutGlobalScopes();
}
} else {
if ($user && $user->tenant_id) {
$storeQuery->where('tenant_id', $user->tenant_id);
}
}
if ($request->filled('service_area_id')) {
$storeQuery->whereHas('barangay.serviceAreas', function ($q) use ($request) {
$q->where('service_areas.id', $request->integer('service_area_id'));
});
}
if ($request->filled('barangay_id')) {
$storeQuery->where('barangay_id', $request->integer('barangay_id'));
}
$storeIds = $storeQuery->pluck('id');
$driver = \DB::getDriverName();
$isSqlite = $driver === 'sqlite';
$query = StoreSale::query()->whereIn('store_id', $storeIds);
switch ($period) {
case 'weekly':
$dateExpression = $isSqlite
? "strftime('%Y-W%W', COALESCE(sold_at, created_at))"
: "DATE_FORMAT(COALESCE(sold_at, created_at), '%x-W%v')";
$rows = $query
->selectRaw("{$dateExpression} as label, SUM(quantity) as total")
->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subWeeks(12)->startOfWeek()])
->groupByRaw($dateExpression)
->orderByRaw($dateExpression)
->get();
break;
case 'monthly':
$dateExpression = $isSqlite
? "strftime('%Y-%m', COALESCE(sold_at, created_at))"
: "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')";
$rows = $query
->selectRaw("{$dateExpression} as label, SUM(quantity) as total")
->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subMonths(12)->startOfMonth()])
->groupByRaw($dateExpression)
->orderByRaw($dateExpression)
->get();
break;
case 'overall':
$dateExpression = $isSqlite
? "strftime('%Y-%m', COALESCE(sold_at, created_at))"
: "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')";
$rows = $query
->selectRaw("{$dateExpression} as label, SUM(quantity) as total")
->groupByRaw($dateExpression)
->orderByRaw($dateExpression)
->get();
break;
default: // daily — last 30 days
$dateExpression = $isSqlite
? "date(COALESCE(sold_at, created_at))"
: "DATE(COALESCE(sold_at, created_at))";
$rows = $query
->selectRaw("{$dateExpression} as label, SUM(quantity) as total")
->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subDays(29)->startOfDay()])
->groupByRaw($dateExpression)
->orderByRaw($dateExpression)
->get();
break;
}
return $this->ok([
'period' => $period,
'labels' => $rows->pluck('label'),
'values' => $rows->pluck('total')->map(fn ($v) => (int) $v),
'total_distributed' => (int) $query->sum('quantity'),
]);
}
}

View File

@@ -0,0 +1,373 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\CollectionLog;
use App\Models\CollectionTeam;
use App\Models\Tenant;
use App\Models\Trip;
use App\Models\TripTimelineEvent;
use App\Tenancy\Tenancy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class AdminTeamReportController extends ApiController
{
/**
* GET /api/v1/admin/reports/teams/leaderboard
*
* Returns a ranked list of all collection teams with aggregate KPIs
* for the given date range. Super-admins can pass tenant_id to scope
* to a specific LGU, or omit it to see all LGUs combined.
*/
public function leaderboard(Request $request): JsonResponse
{
$data = $request->validate([
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
]);
$from = Carbon::parse($data['from'] ?? now()->subDays(30))->startOfDay();
$to = Carbon::parse($data['to'] ?? now())->endOfDay();
$perPage = (int) ($data['per_page'] ?? 50);
$tenant = $this->resolveTargetTenant($request);
// For the leaderboard we run a single DB query per team to avoid N+1.
// We use subquery aggregates to compute everything in one pass.
$rows = Tenancy::withoutScope(function () use ($from, $to, $tenant, $perPage) {
$query = CollectionTeam::withoutTrashed()
->with(['driver', 'scanner', 'truck'])
->when($tenant, fn ($q) => $q->where('collection_teams.tenant_id', $tenant->id));
// Eager-load trip IDs, then compute aggregates in PHP to avoid
// complex cross-table subqueries that differ by DB engine.
return $query->orderBy('name')->paginate($perPage);
});
$teamIds = $rows->pluck('id');
$fromStr = $from->toDateTimeString();
$toStr = $to->toDateTimeString();
// Batch-load all aggregate data using a single query per metric.
$tripStats = Tenancy::withoutScope(function () use ($teamIds, $from, $to) {
return DB::table('trips')
->select(
'team_id',
DB::raw('COUNT(*) as total_trips'),
DB::raw("SUM(CASE WHEN status IN ('completed','handed_off') THEN 1 ELSE 0 END) as completed_trips"),
DB::raw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_trips"),
DB::raw('SUM(COALESCE(total_load_kg, 0)) as total_weight_kg'),
DB::raw('MAX(scheduled_date) as last_trip_date')
)
->whereIn('team_id', $teamIds)
->whereBetween('scheduled_date', [$from->toDateString(), $to->toDateString()])
->whereNull('deleted_at')
->groupBy('team_id')
->get()
->keyBy('team_id');
});
$scanStats = Tenancy::withoutScope(function () use ($teamIds, $fromStr, $toStr) {
return DB::table('collection_logs')
->join('trips', 'trips.id', '=', 'collection_logs.trip_id')
->select('trips.team_id', DB::raw('COUNT(collection_logs.id) as total_scans'))
->whereIn('trips.team_id', $teamIds)
->whereBetween('collection_logs.scanned_at', [$fromStr, $toStr])
->whereNull('trips.deleted_at')
->groupBy('trips.team_id')
->get()
->keyBy('team_id');
});
$eventStats = Tenancy::withoutScope(function () use ($teamIds, $fromStr, $toStr) {
return DB::table('trip_timeline_events')
->join('trips', 'trips.id', '=', 'trip_timeline_events.trip_id')
->select('trips.team_id', DB::raw('COUNT(trip_timeline_events.id) as event_count'))
->whereIn('trips.team_id', $teamIds)
->whereBetween('trip_timeline_events.event_at', [$fromStr, $toStr])
->whereNull('trips.deleted_at')
->groupBy('trips.team_id')
->get()
->keyBy('team_id');
});
// Map teams to leaderboard rows.
$leaderboard = $rows->map(function (CollectionTeam $team) use ($tripStats, $scanStats, $eventStats) {
$ts = $tripStats->get($team->id);
$ss = $scanStats->get($team->id);
$es = $eventStats->get($team->id);
$totalTrips = (int) ($ts->total_trips ?? 0);
$completedTrips = (int) ($ts->completed_trips ?? 0);
$totalWeight = (int) ($ts->total_weight_kg ?? 0);
$totalScans = (int) ($ss->total_scans ?? 0);
return [
'team_uuid' => $team->uuid,
'team_name' => $team->name,
'driver_name' => $team->driver?->full_name,
'scanner_name' => $team->scanner?->full_name,
'truck_plate' => $team->truck?->plate_number,
'status' => $team->status,
'total_trips' => $totalTrips,
'completed_trips' => $completedTrips,
'cancelled_trips' => (int) ($ts->cancelled_trips ?? 0),
'completion_rate_percent'=> $totalTrips > 0
? round(($completedTrips / $totalTrips) * 100, 1)
: 0,
'total_scans' => $totalScans,
'total_weight_kg' => $totalWeight,
'avg_load_per_trip_kg' => $totalTrips > 0
? (int) round($totalWeight / $totalTrips)
: 0,
'event_count' => (int) ($es->event_count ?? 0),
'last_trip_date' => $ts?->last_trip_date,
];
})->sortByDesc('total_scans')->values();
return $this->ok($leaderboard, null, [
'page' => $rows->currentPage(),
'per_page' => $rows->perPage(),
'total' => $rows->total(),
'last_page' => $rows->lastPage(),
'from' => $from->toDateString(),
'to' => $to->toDateString(),
]);
}
/**
* GET /api/v1/admin/reports/teams/{team:uuid}/profile
*
* Returns the full analytics profile for a single team:
* KPIs, daily scan trend, weekly scan trend, trip history, and full event log.
*/
public function teamProfile(Request $request, CollectionTeam $team): JsonResponse
{
$data = $request->validate([
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'trip_page' => ['nullable', 'integer', 'min:1'],
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
]);
$from = Carbon::parse($data['from'] ?? now()->subDays(30))->startOfDay();
$to = Carbon::parse($data['to'] ?? now())->endOfDay();
$tripPage = (int) ($data['trip_page'] ?? 1);
$fromDate = $from->toDateString();
$toDate = $to->toDateString();
$fromDT = $from->toDateTimeString();
$toDT = $to->toDateTimeString();
// Re-load team fully (route model binding already resolved it, but load relations)
$team->load(['driver', 'scanner', 'truck', 'area']);
// Get all trip IDs for this team in range.
$tripIds = Tenancy::withoutScope(function () use ($team, $fromDate, $toDate) {
return Trip::withoutGlobalScopes()
->where('team_id', $team->id)
->whereBetween('scheduled_date', [$fromDate, $toDate])
->pluck('id');
});
// --- KPIs ---
$kpis = $this->buildKpis($team->id, $tripIds, $fromDate, $toDate, $fromDT, $toDT);
// --- Daily scans (group by DATE) ---
$dailyScans = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
return DB::table('collection_logs')
->selectRaw('DATE(scanned_at) as date, COUNT(*) as scans')
->whereIn('trip_id', $tripIds)
->whereBetween('scanned_at', [$fromDT, $toDT])
->groupByRaw('DATE(scanned_at)')
->orderBy('date')
->get()
->map(fn ($r) => ['date' => $r->date, 'scans' => (int) $r->scans]);
});
// --- Weekly scans (group by YEARWEEK) ---
$weeklyScans = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
return DB::table('collection_logs')
->selectRaw("DATE(DATE_SUB(scanned_at, INTERVAL WEEKDAY(scanned_at) DAY)) as week_start, COUNT(*) as scans")
->whereIn('trip_id', $tripIds)
->whereBetween('scanned_at', [$fromDT, $toDT])
->groupByRaw("DATE(DATE_SUB(scanned_at, INTERVAL WEEKDAY(scanned_at) DAY))")
->orderBy('week_start')
->get()
->map(fn ($r) => ['week_start' => $r->week_start, 'scans' => (int) $r->scans]);
});
// --- Trip history (paginated, 10/page) ---
$tripsData = Tenancy::withoutScope(function () use ($team, $fromDate, $toDate, $tripPage, $tripIds) {
$perPage = 10;
$trips = Trip::withoutGlobalScopes()
->withCount('collectionLogs')
->where('team_id', $team->id)
->whereBetween('scheduled_date', [$fromDate, $toDate])
->orderByDesc('scheduled_date')
->paginate($perPage, ['*'], 'trip_page', $tripPage);
return [
'data' => $trips->map(fn (Trip $t) => [
'trip_number' => $t->trip_number,
'scheduled_date' => $t->scheduled_date?->toDateString(),
'status' => $t->status,
'scans_count' => $t->collection_logs_count,
'total_load_kg' => (int) ($t->total_load_kg ?? 0),
'duration_minutes' => ($t->actual_start_time && $t->actual_end_time)
? (int) $t->actual_start_time->diffInMinutes($t->actual_end_time)
: null,
'actual_start_time' => $t->actual_start_time?->toIso8601String(),
'actual_end_time' => $t->actual_end_time?->toIso8601String(),
]),
'meta' => [
'page' => $trips->currentPage(),
'per_page' => $trips->perPage(),
'total' => $trips->total(),
'last_page' => $trips->lastPage(),
],
];
});
// --- Event log (all event types, last 200) ---
$events = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
return DB::table('trip_timeline_events')
->join('trips', 'trips.id', '=', 'trip_timeline_events.trip_id')
->select(
'trip_timeline_events.event_type',
'trip_timeline_events.event_at',
'trip_timeline_events.notes',
'trip_timeline_events.metadata',
'trips.trip_number'
)
->whereIn('trip_timeline_events.trip_id', $tripIds)
->whereBetween('trip_timeline_events.event_at', [$fromDT, $toDT])
->orderByDesc('trip_timeline_events.event_at')
->limit(200)
->get()
->map(fn ($e) => [
'event_type' => $e->event_type,
'event_at' => $e->event_at,
'trip_number' => $e->trip_number,
'notes' => $e->notes,
'metadata' => $e->metadata ? json_decode($e->metadata, true) : null,
]);
});
return $this->ok([
'team' => [
'uuid' => $team->uuid,
'name' => $team->name,
'status' => $team->status,
'area' => $team->area?->name,
'driver' => $team->driver ? [
'name' => $team->driver->full_name,
'email' => $team->driver->email,
] : null,
'scanner' => $team->scanner ? [
'name' => $team->scanner->full_name,
'email' => $team->scanner->email,
] : null,
'truck' => $team->truck ? [
'plate' => $team->truck->plate_number,
'model' => $team->truck->model,
'capacity_kg' => $team->truck->capacity_kg,
] : null,
],
'kpis' => $kpis,
'daily_scans' => $dailyScans,
'weekly_scans' => $weeklyScans,
'trips' => $tripsData,
'events' => $events,
]);
}
/**
* Compute all KPI metrics for a team within the given date range.
*/
private function buildKpis(int $teamId, $tripIds, string $fromDate, string $toDate, string $fromDT, string $toDT): array
{
$tripStats = Tenancy::withoutScope(function () use ($teamId, $fromDate, $toDate) {
return DB::table('trips')
->select(
DB::raw('COUNT(*) as total_trips'),
DB::raw("SUM(CASE WHEN status IN ('completed','handed_off') THEN 1 ELSE 0 END) as completed_trips"),
DB::raw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_trips"),
DB::raw('SUM(COALESCE(total_load_kg,0)) as total_weight_kg'),
// On-time: actual_start_time <= scheduled_start_time + 30 minutes
DB::raw("SUM(CASE
WHEN actual_start_time IS NOT NULL AND scheduled_start_time IS NOT NULL
AND actual_start_time <= DATE_ADD(
CONCAT(scheduled_date, ' ', scheduled_start_time),
INTERVAL 30 MINUTE
)
THEN 1 ELSE 0 END) as on_time_trips")
)
->where('team_id', $teamId)
->whereBetween('scheduled_date', [$fromDate, $toDate])
->whereNull('deleted_at')
->first();
});
$scanStats = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
return DB::table('collection_logs')
->selectRaw('COUNT(*) as total_scans, SUM(COALESCE(weight_kg,0)) as total_scan_weight')
->whereIn('trip_id', $tripIds)
->whereBetween('scanned_at', [$fromDT, $toDT])
->first();
});
$eventStats = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
return DB::table('trip_timeline_events')
->selectRaw("
COUNT(*) as total_events,
SUM(CASE WHEN event_type IN ('incident_reported','breakdown') THEN 1 ELSE 0 END) as incident_count,
SUM(CASE WHEN event_type = 'stop_skipped' THEN 1 ELSE 0 END) as stops_skipped_count,
SUM(CASE WHEN event_type = 'detour_to_dumpsite' THEN 1 ELSE 0 END) as detours_count,
SUM(CASE WHEN event_type = 'breakdown' THEN 1 ELSE 0 END) as breakdowns_count,
SUM(CASE WHEN event_type = 'truck_full_warning' THEN 1 ELSE 0 END) as truck_full_count
")
->whereIn('trip_id', $tripIds)
->whereBetween('event_at', [$fromDT, $toDT])
->first();
});
$totalTrips = (int) ($tripStats->total_trips ?? 0);
$completedTrips = (int) ($tripStats->completed_trips ?? 0);
$onTimeTrips = (int) ($tripStats->on_time_trips ?? 0);
$totalWeight = (int) ($tripStats->total_weight_kg ?? 0);
$totalScans = (int) ($scanStats->total_scans ?? 0);
return [
'total_trips' => $totalTrips,
'completed_trips' => $completedTrips,
'cancelled_trips' => (int) ($tripStats->cancelled_trips ?? 0),
'completion_rate_percent' => $totalTrips > 0 ? round(($completedTrips / $totalTrips) * 100, 1) : 0,
'on_time_trips' => $onTimeTrips,
'on_time_rate_percent' => $completedTrips > 0 ? round(($onTimeTrips / $completedTrips) * 100, 1) : 0,
'total_scans' => $totalScans,
'total_weight_kg' => $totalWeight,
'avg_load_per_trip_kg' => $totalTrips > 0 ? (int) round($totalWeight / $totalTrips) : 0,
'incident_count' => (int) ($eventStats->incident_count ?? 0),
'stops_skipped_count' => (int) ($eventStats->stops_skipped_count ?? 0),
'detours_count' => (int) ($eventStats->detours_count ?? 0),
'breakdowns_count' => (int) ($eventStats->breakdowns_count ?? 0),
'truck_full_count' => (int) ($eventStats->truck_full_count ?? 0),
'total_events' => (int) ($eventStats->total_events ?? 0),
];
}
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();
}
}

View File

@@ -290,7 +290,7 @@ class AdminUserController extends ApiController
return $this->ok([
'user' => new UserDetailResource($user),
'household' => new HouseholdResource($household->load(['head', 'barangay'])),
'household' => new HouseholdResource($household->load(['head', 'barangay.serviceAreas'])),
], 'User successfully grouped into household');
}
}

View File

@@ -21,7 +21,13 @@ class BarangayController extends ApiController
$user = $request->user();
$barangays = Barangay::query()
->when($user && $user->tenant_id, function ($q) use ($user) {
->when($request->filled('tenant_id'), function ($q) use ($request) {
$tenant = Tenant::find($request->integer('tenant_id'));
if ($tenant) {
$q->where('city_municipality_id', $tenant->city_municipality_id);
}
})
->when(!$request->filled('tenant_id') && $user && $user->tenant_id, function ($q) use ($user) {
$tenant = $user->tenant ?: Tenant::find($user->tenant_id);
if ($tenant) {
$q->where('city_municipality_id', $tenant->city_municipality_id);

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\V1\Geo;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FetchBoundaryController extends ApiController
{
public function __invoke(Request $request): JsonResponse
{
$request->validate([
'q' => ['required', 'string', 'max:100'],
]);
$searchQuery = $request->string('q');
$user = $request->user();
$lguSuffix = '';
if ($user && $user->tenant_id) {
$tenant = $user->tenant ?: Tenant::find($user->tenant_id);
if ($tenant && $tenant->cityMunicipality) {
$lguSuffix = ', ' . $tenant->cityMunicipality->name;
}
}
$fullQuery = $searchQuery . $lguSuffix . ', Philippines';
// Cache lookup results to protect Nominatim API rate limits
$cacheKey = 'geo_boundary_' . md5(strtolower($fullQuery));
$results = Cache::remember($cacheKey, now()->addDays(7), function () use ($fullQuery) {
try {
$response = Http::withHeaders([
'User-Agent' => 'Verde Waste Management App (admin@verde.local)',
])->get('https://nominatim.openstreetmap.org/search', [
'q' => $fullQuery,
'format' => 'json',
'polygon_geojson' => 1,
'limit' => 5,
]);
if ($response->successful()) {
$data = $response->json() ?? [];
return collect($data)
->filter(fn ($item) => isset($item['geojson']))
->map(fn ($item) => [
'display_name' => $item['display_name'] ?? 'Unknown Location',
'geojson' => $item['geojson'],
])
->values()
->all();
}
} catch (\Exception $e) {
Log::error('Nominatim boundary fetch failed: ' . $e->getMessage());
}
return [];
});
return $this->ok($results);
}
}

View File

@@ -12,6 +12,9 @@ use App\Models\ServiceArea;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use MatanYadaev\EloquentSpatial\Objects\LineString;
use MatanYadaev\EloquentSpatial\Objects\Point;
use MatanYadaev\EloquentSpatial\Objects\Polygon;
class ServiceAreaController extends ApiController
{
@@ -23,6 +26,9 @@ class ServiceAreaController extends ApiController
]);
$areas = ServiceArea::query()
->when($request->filled('tenant_id'), function ($q) use ($request) {
$q->withoutGlobalScopes()->where('tenant_id', $request->integer('tenant_id'));
})
->withCount('barangays')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->string('q').'%'))
@@ -38,6 +44,10 @@ class ServiceAreaController extends ApiController
$barangayIds = $data['barangay_ids'] ?? [];
unset($data['barangay_ids']);
if (! empty($data['boundary'])) {
$data['boundary'] = $this->buildPolygon($data['boundary']);
}
$area = DB::transaction(function () use ($data, $barangayIds) {
$area = ServiceArea::create($data);
if (! empty($barangayIds)) {
@@ -67,6 +77,10 @@ class ServiceAreaController extends ApiController
$barangayIds = $data['barangay_ids'] ?? null;
unset($data['barangay_ids']);
if (array_key_exists('boundary', $data)) {
$data['boundary'] = ! empty($data['boundary']) ? $this->buildPolygon($data['boundary']) : null;
}
DB::transaction(function () use ($serviceArea, $data, $barangayIds) {
$serviceArea->update($data);
if ($barangayIds !== null) {
@@ -80,6 +94,22 @@ class ServiceAreaController extends ApiController
);
}
private function buildPolygon(array $points): Polygon
{
$ring = array_map(
fn ($p) => new Point((float) $p['lat'], (float) $p['lng'], 4326),
$points,
);
$first = $ring[0];
$last = $ring[count($ring) - 1];
if ($first->latitude !== $last->latitude || $first->longitude !== $last->longitude) {
$ring[] = new Point($first->latitude, $first->longitude, 4326);
}
return new Polygon([new LineString($ring)], 4326);
}
public function destroy(ServiceArea $serviceArea): JsonResponse
{
$serviceArea->delete();

View File

@@ -23,7 +23,7 @@ class MyHouseholdController extends ApiController
$tenantId = Tenancy::current()?->id;
Log::info("DEBUG HOUSEHOLD show for User $userId, Tenant: $tenantId");
$household = Household::with([
'head', 'barangay.cityMunicipality.province.region',
'head', 'barangay.cityMunicipality.province.region', 'barangay.serviceAreas',
'members.user',
'assignedDropOffPoint',
])

View File

@@ -191,7 +191,7 @@ class PaymentController extends ApiController
}
try {
$stores->sellToHousehold($store, $household, $qty, $price);
$stores->sell($store, $household, null, $qty, $price);
} catch (\DomainException $e) {
$payment->forceFill(['status' => Payment::STATUS_FAILED])->save();
\Log::warning('Fulfillment failed', ['payment' => $payment->uuid, 'error' => $e->getMessage()]);

View File

@@ -49,6 +49,8 @@ class MyQrCodeController extends ApiController
$household = Household::where('head_user_id', $request->user()->id)->first();
if (! $household) {
return $this->ok([
'balance' => 0,
'household_name' => 'No Household',
'active' => 0, 'allocated' => 0, 'used' => 0, 'expired' => 0,
'low_balance' => false,
]);

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Api\V1\Resident;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\QrPurchaseOrder;
use App\Models\PartnerStore;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class QrPurchaseController extends ApiController
{
public function index(Request $request): JsonResponse
{
$orders = QrPurchaseOrder::with(['store.storePartnerProfile'])
->where('resident_id', $request->user()->id)
->orderByDesc('created_at')
->get();
return $this->ok($orders);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'store_id' => 'required|exists:partner_stores,uuid',
]);
$partnerStore = PartnerStore::where('uuid', $request->store_id)->firstOrFail();
// Enforce LGU constraint: Resident and Store must belong to the same tenant
if ($partnerStore->tenant_id !== $request->user()->tenant_id) {
return $this->fail('The selected store does not belong to your LGU.', null, 403);
}
$order = QrPurchaseOrder::create([
'resident_id' => $request->user()->id,
'store_id' => $partnerStore->owner_user_id,
'tenant_id' => $request->user()->tenant_id,
'amount' => config('qr.default_retail_price_per_code_centavos', 1000) / 100.0,
]);
return $this->created($order->load('store.storePartnerProfile'), 'QR Reservation created successfully');
}
}

View File

@@ -412,12 +412,89 @@ class StorePortalController extends ApiController
public function getSettings(): JsonResponse
{
$store = $this->getStore();
$store->loadMissing('inventory');
$tenant = $store->tenant;
return $this->ok([
'retail_price_centavos' => (int) ($tenant->qr_retail_price_centavos ?? 1000),
'retail_price_pesos' => ($tenant->qr_retail_price_centavos ?? 1000) / 100,
'commission_rate_percent' => (int) $store->commission_rate_percent,
'inventory_balance' => (int) ($store->inventory?->current_code_balance ?? 0),
]);
}
/**
* Get store profile details.
*/
public function profile(): JsonResponse
{
$store = $this->getStore();
$store->load(['owner', 'barangay', 'tenant']);
$barangayBoundary = null;
if ($store->barangay && $store->barangay->boundary) {
$rings = $store->barangay->boundary->getGeometries();
$ring = $rings->first();
if ($ring) {
$barangayBoundary = $ring->getGeometries()
->map(fn ($p) => ['lat' => $p->latitude, 'lng' => $p->longitude])
->all();
}
}
return $this->ok([
'id' => $store->uuid,
'business_name' => $store->business_name,
'business_permit_number' => $store->business_permit_number,
'address_line' => $store->address_line,
'barangay_name' => $store->barangay?->name ?? 'N/A',
'lgu_name' => $store->tenant?->name ?? 'N/A',
'barangay_boundary' => $barangayBoundary,
'commission_rate_percent' => (int) $store->commission_rate_percent,
'status' => $store->status,
'operating_hours' => $store->operating_hours,
'owner' => [
'name' => $store->owner?->full_name,
'email' => $store->owner?->email,
'phone' => $store->owner?->phone,
],
'coordinates' => $store->coordinates ? [
'lat' => $store->coordinates->latitude,
'lng' => $store->coordinates->longitude,
] : null,
]);
}
/**
* Update store profile details.
*/
public function updateProfile(Request $request): JsonResponse
{
$store = $this->getStore();
$data = $request->validate([
'business_name' => 'required|string|max:255',
'business_permit_number' => 'nullable|string|max:255',
'address_line' => 'required|string|max:255',
'operating_hours' => 'nullable|array',
'operating_hours.open' => 'nullable|string',
'operating_hours.close' => 'nullable|string',
'owner_phone' => 'nullable|string|max:255',
]);
$store->update([
'business_name' => $data['business_name'],
'business_permit_number' => $data['business_permit_number'] ?? null,
'address_line' => $data['address_line'],
'operating_hours' => $data['operating_hours'] ?? null,
]);
if ($store->owner && isset($data['owner_phone'])) {
$store->owner->update([
'phone' => $data['owner_phone'],
]);
}
return $this->ok(null, 'Profile updated successfully.');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api\V1\Store;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\QrCode;
use App\Models\QrPurchaseOrder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StoreQrPurchaseController extends ApiController
{
public function pending(Request $request): JsonResponse
{
$orders = QrPurchaseOrder::with(['resident'])
->where('store_id', $request->user()->id)
->where('status', 'pending_payment')
->orderBy('created_at')
->get();
return $this->ok($orders);
}
public function complete(Request $request, string $uuid): JsonResponse
{
$request->validate([
'scanned_qr_data' => 'required|string',
]);
$order = QrPurchaseOrder::where('uuid', $uuid)
->where('store_id', $request->user()->id)
->where('status', 'pending_payment')
->firstOrFail();
// Find the scanned QR code
$qrCode = QrCode::where('serial', $request->scanned_qr_data)->first();
if (!$qrCode) {
return $this->fail('Invalid QR Code. Not found in the system.', null, 404);
}
if (! $qrCode->status->equals(\App\States\QrCode\Allocated::class) && ! $qrCode->status->equals(\App\States\QrCode\Unassigned::class)) {
return $this->fail('This QR Code cannot be assigned (already used or active).', null, 422);
}
DB::transaction(function () use ($order, $qrCode) {
$order->update([
'status' => 'completed',
'completed_at' => now(),
'qr_code_id' => $qrCode->id,
]);
$qrCode->status->transitionTo(\App\States\QrCode\Active::class);
$qrCode->update([
'assigned_to_user_id' => $order->resident_id,
'activated_at' => now(),
]);
});
return $this->ok($order->fresh(), 'QR Purchase completed and assigned successfully.');
}
}

View File

@@ -25,4 +25,14 @@ class StoreDashboardController extends Controller
{
return view('store.financials');
}
public function qrPurchases()
{
return view('store.qr-purchases');
}
public function profile()
{
return view('store.profile');
}
}