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');
}
}

View File

@@ -22,6 +22,9 @@ class StoreServiceAreaRequest extends FormRequest
'description' => ['nullable', 'string', 'max:1000'],
'barangay_ids' => ['nullable', 'array'],
'barangay_ids.*' => ['integer', 'exists:barangays,id'],
'boundary' => ['nullable', 'array'],
'boundary.*.lat' => ['required', 'numeric'],
'boundary.*.lng' => ['required', 'numeric'],
];
}
}

View File

@@ -30,6 +30,9 @@ class UpdateServiceAreaRequest extends FormRequest
'description' => ['sometimes', 'nullable', 'string', 'max:1000'],
'barangay_ids' => ['nullable', 'array'],
'barangay_ids.*' => ['integer', 'exists:barangays,id'],
'boundary' => ['nullable', 'array'],
'boundary.*.lat' => ['required', 'numeric'],
'boundary.*.lng' => ['required', 'numeric'],
];
}
}

View File

@@ -20,6 +20,7 @@ class HouseholdResource extends JsonResource
'lng' => $this->coordinates->longitude,
] : null,
'barangay' => BarangayResource::make($this->whenLoaded('barangay')),
'service_area' => $this->barangay?->serviceAreas->first()?->name ?? 'None',
'head' => UserResource::make($this->whenLoaded('head')),
'verification_status' => $this->verification_status,
'verified_at' => $this->verified_at?->toIso8601String(),

View File

@@ -9,6 +9,17 @@ class ServiceAreaResource extends JsonResource
{
public function toArray(Request $request): array
{
$boundaryPoints = null;
if ($this->boundary) {
$rings = $this->boundary->getGeometries();
$ring = $rings->first();
if ($ring) {
$boundaryPoints = $ring->getGeometries()
->map(fn ($p) => ['lat' => $p->latitude, 'lng' => $p->longitude])
->all();
}
}
return [
'id' => $this->uuid,
'db_id' => $this->id,
@@ -16,6 +27,7 @@ class ServiceAreaResource extends JsonResource
'code' => $this->code,
'status' => $this->status,
'description' => $this->description,
'boundary' => $boundaryPoints,
'barangay_count' => $this->whenCounted('barangays'),
'barangays' => BarangayResource::collection($this->whenLoaded('barangays')),
'created_at' => $this->created_at?->toIso8601String(),

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class QrPurchaseOrder extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'uuid',
'resident_id',
'store_id',
'tenant_id',
'reservation_code',
'status',
'amount',
'completed_at',
'qr_code_id',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'completed_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (self $order): void {
if (empty($order->uuid)) {
$order->uuid = (string) Str::uuid();
}
if (empty($order->reservation_code)) {
// Generate a 6-character alphanumeric code
$order->reservation_code = strtoupper(Str::random(6));
}
});
}
public function resident(): BelongsTo
{
return $this->belongsTo(User::class, 'resident_id');
}
public function store(): BelongsTo
{
return $this->belongsTo(User::class, 'store_id');
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
// Optional: relation to QrCode if the QrCode model exists
// public function qrCode(): BelongsTo
// {
// return $this->belongsTo(QrCode::class);
// }
}

View File

@@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use MatanYadaev\EloquentSpatial\Objects\Polygon;
class ServiceArea extends Model
{
use HasFactory, HasTenant, SoftDeletes;
@@ -24,8 +26,16 @@ class ServiceArea extends Model
'code',
'status',
'description',
'boundary',
];
protected function casts(): array
{
return [
'boundary' => Polygon::class,
];
}
public function getRouteKeyName(): string
{
return 'uuid';

View File

@@ -10,7 +10,7 @@ return [
| When a household's active code count drops to or below this number,
| a QrBalanceLow event fires (notification listeners hook off it).
*/
'low_balance_threshold' => (int) env('QR_LOW_BALANCE_THRESHOLD', 5),
'low_balance_threshold' => (int) env('QR_LOW_BALANCE_THRESHOLD', 2),
/*
| Default expiry for a generated batch (months from creation). Null

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('qr_purchase_orders', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('resident_id')->constrained('users')->onDelete('cascade');
$table->foreignId('store_id')->constrained('users')->onDelete('cascade');
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->string('reservation_code')->unique();
$table->enum('status', ['pending_payment', 'completed', 'cancelled'])->default('pending_payment');
$table->decimal('amount', 8, 2)->default(50.00);
$table->timestamp('completed_at')->nullable();
$table->foreignId('qr_code_id')->nullable()->constrained('qr_codes')->nullOnDelete();
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('qr_purchase_orders');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_areas', function (Blueprint $table) {
$table->geometry('boundary', subtype: 'polygon', srid: 4326)->nullable()->after('description');
});
}
public function down(): void
{
Schema::table('service_areas', function (Blueprint $table) {
$table->dropColumn('boundary');
});
}
};

48
geofence-fetch.md Normal file
View File

@@ -0,0 +1,48 @@
# Plan - Geofence Fetching and Editing
## Goal
Implement a dynamic geofence fetching API (via OpenStreetMap Nominatim proxy) and integrate Leaflet-Geoman in the Admin views to allow creating, editing, and saving geofence boundaries for Tenants (LGUs), Service Areas, and Barangays.
---
## Proposed Changes
### 1. Database Layer
- **Migration**: Create a migration to add a `boundary` polygon column (spatial `Polygon` or `MultiPolygon`) to the `service_areas` table:
- File: `[NEW] database/migrations/[timestamp]_add_boundary_to_service_areas_table.php`
- **Model**: Add the spatial cast to `App\Models\ServiceArea`:
- Property: `$casts = ['boundary' => PolygonCast::class]`
### 2. Backend API Layer
- **Geo Controller**: Add `/api/v1/geo/fetch-boundary` endpoint.
- Suffixes queries with the active tenant's City/Municipality name (e.g., `", Quezon City, Philippines"`).
- Fetches from Nominatim, cleans the GeoJSON geometry, and returns standard geo json format.
- Caches the responses using Laravel Cache.
- Controller: `[NEW] app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php`
- Routes: Add route under `routes/api.php`
- **Service Area API**: Update `ServiceAreaController` (and request validation) to support storing/updating `boundary`.
- **Tenant API**: Update Tenant controller/requests to allow updating LGU `boundary_polygon`.
- **Barangay API**: Ensure admin can update a Barangay's boundary.
### 3. Frontend UI Integration
- **Leaflet-Geoman Library**: Include the library (JS and CSS) via UNPKG CDN in the admin layout:
- File: `resources/views/admin/layouts/app.blade.php`
- **Service Areas Dashboard**:
- Integrate a search/fetch text input and button next to the map.
- Enable Geoman toolbar controls on the map (`pm:draw`, `pm:edit`, `pm:drag`, `pm:delete`).
- Wire up form submission to send the edited polygon coordinates as GeoJSON array to the backend.
- **Other Geofenced Pages**: Integrate Geoman editing capabilities for LGU boundary management and Barangay maps.
---
## Verification Plan
### Automated Tests
- Create `tests/Feature/Api/V1/Geo/FetchBoundaryTest.php` to verify:
- Fetch boundary successfully calls Nominatim proxy.
- Query parameters automatically include LGU suffix context.
- GeoJSON response formats correctly.
### Manual Verification
- Navigate to **Service Areas** page, click "Edit", fetch a boundary (e.g., "Baesa"), reshape the geofence, and save.
- Verify the saved geofence is retrieved and displayed on reload.

70
qr-purchase-flow.md Normal file
View File

@@ -0,0 +1,70 @@
# QR Purchase Flow
This plan outlines the implementation for the "Reservation Ticket" (Option A) approach for QR Code purchasing, constrained to the user's Local Government Unit (LGU).
## Goal
Residents can reserve a physical QR code at a local store via the mobile app. The store confirms cash payment via the store web portal and hands over/links the physical QR sticker to the resident's account.
## Constraint
Residents can only choose stores that belong to their LGU (`tenant_id`).
---
## Task Breakdown
### 1. Database Schema
- **Create `QrPurchaseOrder` Model & Migration**
- `id`, `uuid`
- `resident_id` (foreign key to users)
- `store_id` (foreign key to users where role=store_partner)
- `tenant_id` (foreign key to tenants)
- `reservation_code` (unique string, e.g., "QR-123456")
- `status` (enum: 'pending_payment', 'completed', 'cancelled')
- `amount` (decimal)
- `completed_at` (timestamp, nullable)
- `qr_code_id` (foreign key to the generated/assigned QR code, nullable)
- `timestamps`
### 2. Backend API (Resident App)
- **GET `/api/v1/resident/stores` (Update)**
- Filter stores by the resident's `tenant_id`.
- Include store address, business name, and stock availability (if tracked).
- **POST `/api/v1/resident/qr-purchases`**
- Creates a `QrPurchaseOrder` with status `pending_payment`.
- Generates a `reservation_code`.
- **GET `/api/v1/resident/qr-purchases`**
- List the resident's purchase orders (to show their pending reservation code).
### 3. Backend API (Store Portal)
- **GET `/api/v1/store/qr-purchases/pending`**
- List all `pending_payment` orders for the logged-in store.
- **POST `/api/v1/store/qr-purchases/{uuid}/complete`**
- Accepts a `scanned_qr_data` payload.
- Verifies the QR is valid and unassigned.
- Marks the order as `completed`.
- Assigns the QR code to the resident's household/profile.
### 4. Resident App Integration
- **Stores Screen:** Show map/list of LGU stores.
- **Purchase Flow:** "Buy QR" button -> creates reservation -> shows "Reservation Ticket" screen with large code.
### 5. Store Web Portal Integration
- **Pending Orders Tab:** Show a queue of people coming to buy QRs.
- **Completion Flow:** Button to "Confirm Payment & Scan QR". Opens camera or input box to scan the physical sticker and link it.
## Verification Checklist
- [ ] Resident can only see stores in their LGU.
- [ ] Resident can create a reservation and see the code.
- [ ] Store sees the reservation on their dashboard.
- [ ] Store can mark it paid and link a physical QR.
- [ ] Physical QR is successfully linked to the Resident.
---
## Agent Assignments
- **Backend Specialist:** Create the migrations, models, and API endpoints.
- **Frontend Specialist:** Update the Store Web Portal UI.
- **Mobile Developer:** Update the Resident App UI (Flutter/Dart).
> [!IMPORTANT]
> User Review Required: Does the store charge a fixed price, or is it determined dynamically? I will assume a fixed price (e.g., 50 PHP) for now, but please confirm if the price varies.

View File

@@ -116,10 +116,16 @@
{{-- Leaflet Map (7 cols) --}}
<div class="lg:col-span-7 flex flex-col">
<label class="form-label mb-2">Draw Boundary Polygon on Map</label>
<div class="flex items-center justify-between mb-2">
<label class="form-label mb-0">Draw or Fetch Boundary Polygon</label>
<div class="flex gap-1.5 items-center">
<input type="text" id="geofence-search-input" placeholder="e.g. Baesa" class="form-input text-xs py-1 px-2 w-48">
<button type="button" id="geofence-search-btn" class="btn-primary text-xs py-1 px-2.5">Fetch Geofence</button>
</div>
</div>
<div id="barangay-map" class="w-full rounded-lg border border-neutral-200" style="height: 450px;"></div>
<p class="mt-2 text-xs text-neutral-400">
Select a City/Municipality to load its boundaries. Use the drawing toolbar to draw the Barangay polygon boundary inside it.
Select a City/Municipality to load its boundaries. Use the search box to fetch the geofence or use the drawing toolbar.
</p>
</div>
</div>
@@ -328,6 +334,8 @@
document.getElementById('modal-title').textContent = 'New Barangay';
document.getElementById('create-form').reset();
boundaryInput.value = '';
const searchInput = document.getElementById('geofence-search-input');
if (searchInput) searchInput.value = '';
if (activeDrawLayer) {
activeDrawLayer.remove();
@@ -352,6 +360,8 @@
document.getElementById('modal-title').textContent = 'Edit Barangay';
document.getElementById('create-form').reset();
boundaryInput.value = '';
const searchInput = document.getElementById('geofence-search-input');
if (searchInput) searchInput.value = '';
if (activeDrawLayer) {
activeDrawLayer.remove();
@@ -459,6 +469,70 @@
document.getElementById('filter-apply').addEventListener('click', () => load(1));
document.getElementById('geofence-search-btn')?.addEventListener('click', async () => {
const query = document.getElementById('geofence-search-input').value.trim();
if (!query) {
window.Verde.toast('Please enter a location to search.', 'error');
return;
}
const btn = document.getElementById('geofence-search-btn');
btn.disabled = true;
btn.textContent = 'Fetching…';
const res = await window.Verde.apiFetch(`/api/v1/geo/fetch-boundary?q=${encodeURIComponent(query)}`);
btn.disabled = false;
btn.textContent = 'Fetch Geofence';
if (res.ok) {
const list = res.body.data ?? [];
if (list.length === 0) {
window.Verde.toast('No geofence found for this location.', 'error');
return;
}
const item = list[0];
if (activeDrawLayer) {
activeDrawLayer.remove();
activeDrawLayer = null;
}
const L = window.L;
activeDrawLayer = L.geoJSON(item.geojson, {
color: '#2ca02c',
weight: 3,
fillOpacity: 0.2
}).addTo(leafletMap);
// Get boundaries and fit
const bounds = activeDrawLayer.getBounds();
leafletMap.fitBounds(bounds);
// If it's a GeoJSON layer group, extract the actual layer to let Geoman edit it.
let layerToEnable = activeDrawLayer;
if (activeDrawLayer.getLayers) {
const layers = activeDrawLayer.getLayers();
if (layers.length > 0) {
layerToEnable = layers[0];
activeDrawLayer = layerToEnable;
}
}
layerToEnable.pm.enable();
updateBoundaryInput();
layerToEnable.on('pm:edit', updateBoundaryInput);
layerToEnable.on('pm:remove', () => {
activeDrawLayer = null;
boundaryInput.value = '';
});
window.Verde.toast(`Fetched boundary: ${item.display_name}`, 'success');
} else {
window.Verde.toast('Failed to fetch boundary.', 'error');
}
});
// Page initialization
initLists().then(() => {
load();

View File

@@ -10,7 +10,6 @@
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Households</h2>
<p class="mt-1 text-sm text-neutral-500">Verify resident households and view assigned drop-offs.</p>
</div>
<button id="new-btn" class="btn-primary">+ Add Household</button>
</header>
<div class="card mb-4 flex flex-wrap items-center gap-3 p-4">
@@ -20,6 +19,9 @@
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
<select id="filter-service-area" class="form-select w-44">
<option value="">All Service Areas</option>
</select>
<input id="filter-q" type="search" placeholder="Search head, email, address…" class="form-input flex-1 min-w-[200px]">
<button id="filter-apply" class="btn-primary">Apply</button>
</div>
@@ -31,6 +33,7 @@
<tr>
<th>Head</th>
<th>Address</th>
<th>Service Area</th>
<th>Members</th>
<th>Proof</th>
<th>Status</th>
@@ -38,7 +41,7 @@
</tr>
</thead>
<tbody id="rows">
<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
</tbody>
</table>
</div>
@@ -323,6 +326,7 @@
<script type="module">
const rows = document.getElementById('rows');
const filterStatus = document.getElementById('filter-status');
const filterServiceArea = document.getElementById('filter-service-area');
const filterQ = document.getElementById('filter-q');
let currentRejectId = null;
let editingHouseholdId = null;
@@ -338,18 +342,19 @@
async function load() {
const params = new URLSearchParams();
if (filterStatus.value) params.set('verification_status', filterStatus.value);
if (filterServiceArea.value) params.set('service_area_id', filterServiceArea.value);
if (filterQ.value.trim()) params.set('q', filterQ.value.trim());
params.set('per_page', '50');
rows.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
const res = await window.Verde.apiFetch(`/api/v1/admin/households?${params}`);
if (!res.ok) {
rows.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-sm text-red-500">Failed to load.</td></tr>`;
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-red-500">Failed to load.</td></tr>`;
return;
}
const items = res.body.data ?? [];
if (items.length === 0) {
rows.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">No households match.</td></tr>`;
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">No households match.</td></tr>`;
return;
}
rows.innerHTML = items.map(h => {
@@ -380,6 +385,7 @@
<div class="truncate">${window.Verde.escapeHtml(h.address_line)}</div>
${mapLink}
</td>
<td>${window.Verde.escapeHtml(h.service_area ?? 'None')}</td>
<td>${h.member_count ?? '—'}</td>
<td>${proof}</td>
<td>${statusBadge(h.verification_status)}</td>
@@ -486,6 +492,7 @@
document.getElementById('filter-apply').addEventListener('click', load);
filterStatus.addEventListener('change', load);
filterServiceArea.addEventListener('change', load);
filterQ.addEventListener('keydown', (e) => { if (e.key === 'Enter') load(); });
document.getElementById('close-map').addEventListener('click', () => {
@@ -1018,48 +1025,51 @@
}
// Add Household button listener
document.getElementById('new-btn').addEventListener('click', async () => {
editingHouseholdId = null;
editingMemberId = null;
document.getElementById('modal-title').textContent = 'New Household';
createForm.reset();
document.getElementById('form-lat').value = '';
document.getElementById('form-lng').value = '';
if (pickerMarker && pickerMap) {
pickerMap.removeLayer(pickerMarker);
pickerMarker = null;
}
if (pickerBoundaryLayer && pickerMap) {
pickerMap.removeLayer(pickerBoundaryLayer);
pickerBoundaryLayer = null;
}
const newBtn = document.getElementById('new-btn');
if (newBtn) {
newBtn.addEventListener('click', async () => {
editingHouseholdId = null;
editingMemberId = null;
document.getElementById('modal-title').textContent = 'New Household';
createForm.reset();
document.getElementById('form-lat').value = '';
document.getElementById('form-lng').value = '';
if (pickerMarker && pickerMap) {
pickerMap.removeLayer(pickerMarker);
pickerMarker = null;
}
if (pickerBoundaryLayer && pickerMap) {
pickerMap.removeLayer(pickerBoundaryLayer);
pickerBoundaryLayer = null;
}
// Hide edit-only UI
document.getElementById('edit-tab-nav').classList.add('hidden');
document.getElementById('head-user-section').classList.add('hidden');
switchTab('details');
// Hide edit-only UI
document.getElementById('edit-tab-nav').classList.add('hidden');
document.getElementById('head-user-section').classList.add('hidden');
switchTab('details');
// Show proof upload section in create mode
document.getElementById('proof-upload-section').classList.remove('hidden');
// Show proof upload section in create mode
document.getElementById('proof-upload-section').classList.remove('hidden');
// Show default panels
createForm.querySelectorAll('input[name="resident_type"]').forEach(r => {
r.disabled = false;
if (r.value === 'new') r.checked = true;
// Show default panels
createForm.querySelectorAll('input[name="resident_type"]').forEach(r => {
r.disabled = false;
if (r.value === 'new') r.checked = true;
});
createForm.querySelector('input[name="resident_type"]').closest('.space-y-4').classList.remove('hidden');
document.getElementById('new-resident-fields').classList.remove('hidden');
document.getElementById('existing-resident-fields').classList.add('hidden');
formModal.classList.remove('hidden');
await initPickerMap();
await populateBarangays();
setTimeout(() => {
pickerMap.invalidateSize();
}, 100);
});
createForm.querySelector('input[name="resident_type"]').closest('.space-y-4').classList.remove('hidden');
document.getElementById('new-resident-fields').classList.remove('hidden');
document.getElementById('existing-resident-fields').classList.add('hidden');
formModal.classList.remove('hidden');
await initPickerMap();
await populateBarangays();
setTimeout(() => {
pickerMap.invalidateSize();
}, 100);
});
}
// Close button for form-modal
formModal.querySelectorAll('[data-close]').forEach(el => {
@@ -1132,6 +1142,15 @@
}
});
load();
async function populateServiceAreasFilter() {
const res = await window.Verde.apiFetch('/api/v1/service-areas');
if (res.ok) {
const list = res.body.data ?? [];
filterServiceArea.innerHTML = '<option value="">All Service Areas</option>' +
list.map(sa => `<option value="${sa.db_id}">${window.Verde.escapeHtml(sa.name)}</option>`).join('');
}
}
populateServiceAreasFilter().then(() => load());
</script>
@endsection

View File

@@ -395,17 +395,42 @@
searchResults.classList.add('hidden');
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=3`);
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&polygon_geojson=1&q=${encodeURIComponent(query)}&limit=3`);
if (!res.ok) return;
const data = await res.json();
if (data.length > 0) {
if (autoZoom) {
const first = data[0];
const first = data.find(item => item.geojson && (item.geojson.type === 'Polygon' || item.geojson.type === 'MultiPolygon')) || data[0];
pickerMap.setView([parseFloat(first.lat), parseFloat(first.lon)], 13);
if (first.geojson && (first.geojson.type === 'Polygon' || first.geojson.type === 'MultiPolygon')) {
const L = window.L;
drawnItems.clearLayers();
const geojsonLayer = L.geoJSON(first.geojson, {
style: {
color: document.getElementById('theme-color-hex').value || '#16a34a',
weight: 3,
fillOpacity: 0.2
}
});
let firstLayer = null;
geojsonLayer.eachLayer(layer => {
if (!firstLayer) firstLayer = layer;
drawnItems.addLayer(layer);
});
if (firstLayer) {
updatePickerPointsFromLayer(firstLayer);
pickerMap.fitBounds(firstLayer.getBounds());
}
}
} else {
searchResults.innerHTML = data.map(item => `
<li data-lat="${item.lat}" data-lon="${item.lon}" class="px-3 py-2 text-xs border-b border-neutral-100 cursor-pointer hover:bg-neutral-50 text-neutral-800 last:border-b-0">
window._currentGeocodeResults = data;
searchResults.innerHTML = data.map((item, index) => `
<li data-index="${index}" class="px-3 py-2 text-xs border-b border-neutral-100 cursor-pointer hover:bg-neutral-50 text-neutral-800 last:border-b-0">
${window.Verde.escapeHtml(item.display_name)}
</li>
`).join('');
@@ -413,9 +438,36 @@
searchResults.querySelectorAll('li').forEach(li => {
li.addEventListener('click', () => {
pickerMap.setView([parseFloat(li.dataset.lat), parseFloat(li.dataset.lon)], 13);
const index = parseInt(li.dataset.index, 10);
const item = window._currentGeocodeResults[index];
if (!item) return;
pickerMap.setView([parseFloat(item.lat), parseFloat(item.lon)], 13);
searchResults.classList.add('hidden');
document.getElementById('map-search').value = '';
if (item.geojson && (item.geojson.type === 'Polygon' || item.geojson.type === 'MultiPolygon')) {
const L = window.L;
drawnItems.clearLayers();
const geojsonLayer = L.geoJSON(item.geojson, {
style: {
color: document.getElementById('theme-color-hex').value || '#16a34a',
weight: 3,
fillOpacity: 0.2
}
});
let firstLayer = null;
geojsonLayer.eachLayer(layer => {
if (!firstLayer) firstLayer = layer;
drawnItems.addLayer(layer);
});
if (firstLayer) {
updatePickerPointsFromLayer(firstLayer);
pickerMap.fitBounds(firstLayer.getBounds());
}
}
});
});
}

View File

@@ -3,6 +3,11 @@
@section('page')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<style>
.chart-period-btn { color: #737373; }
.chart-period-btn.active { background: white; color: #111827; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
</style>
<div class="mx-auto max-w-7xl">
<header class="mb-6 flex items-end justify-between">
@@ -13,6 +18,45 @@
<button id="new-btn" class="btn-primary">+ New Store</button>
</header>
{{-- Overall QR Distribution Chart (Dashboard level) --}}
<div class="card border border-neutral-200 bg-white p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
<div>
<h3 class="font-bold text-lg text-neutral-900">Overall QR Code Distribution</h3>
<p class="text-xs text-neutral-500 mt-0.5">Total QR codes sold across partner stores</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<select id="chart-filter-lgu" class="form-select text-xs py-1.5 h-8 w-40 hidden">
<option value="">All LGUs</option>
</select>
<select id="chart-filter-service-area" class="form-select text-xs py-1.5 h-8 w-40">
<option value="">All Service Areas</option>
</select>
<select id="chart-filter-barangay" class="form-select text-xs py-1.5 h-8 w-40">
<option value="">All Barangays</option>
</select>
<div class="flex gap-1 bg-neutral-100 rounded-lg p-1">
<button type="button" data-overall-period="daily" class="overall-period-btn active px-3 py-1 rounded-md text-xs font-semibold transition-all">Daily</button>
<button type="button" data-overall-period="weekly" class="overall-period-btn px-3 py-1 rounded-md text-xs font-semibold transition-all">Weekly</button>
<button type="button" data-overall-period="monthly" class="overall-period-btn px-3 py-1 rounded-md text-xs font-semibold transition-all">Monthly</button>
<button type="button" data-overall-period="overall" class="overall-period-btn px-3 py-1 rounded-md text-xs font-semibold transition-all">Overall</button>
</div>
</div>
</div>
<div class="relative" style="height: 250px;">
<canvas id="overall-distribution-chart" style="width:100%;height:100%;"></canvas>
<div id="overall-chart-empty" class="hidden absolute inset-0 flex items-center justify-center text-sm text-neutral-400">No overall sales data found.</div>
<div id="overall-chart-loading" class="absolute inset-0 flex items-center justify-center text-sm text-neutral-400">Loading overall chart…</div>
</div>
<div class="mt-4 flex items-center gap-2 text-xs text-neutral-500">
<span>Total distributed LGU-wide:</span>
<span id="overall-chart-total" class="font-bold text-neutral-900"></span>
<span>QR codes</span>
</div>
</div>
<div class="card mb-4 flex flex-wrap items-center gap-3 p-4">
<select id="filter-status" class="form-select w-44">
<option value="">All statuses</option>
@@ -216,6 +260,32 @@
{{-- TAB: Analytics (edit mode only) --}}
<div id="tab-analytics" class="hidden space-y-6">
{{-- QR Distribution Chart --}}
<div class="card border border-neutral-100 bg-white p-4">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="font-semibold text-sm text-neutral-900">QR Code Distribution</h4>
<p class="text-xs text-neutral-400 mt-0.5">Total QR codes sold to residents over time</p>
</div>
<div class="flex gap-1 bg-neutral-100 rounded-lg p-1">
<button type="button" data-period="daily" class="chart-period-btn active px-3 py-1 rounded-md text-xs font-semibold transition-all">Daily</button>
<button type="button" data-period="weekly" class="chart-period-btn px-3 py-1 rounded-md text-xs font-semibold transition-all">Weekly</button>
<button type="button" data-period="monthly" class="chart-period-btn px-3 py-1 rounded-md text-xs font-semibold transition-all">Monthly</button>
<button type="button" data-period="overall" class="chart-period-btn px-3 py-1 rounded-md text-xs font-semibold transition-all">Overall</button>
</div>
</div>
<div class="relative" style="height: 220px;">
<canvas id="distribution-chart" style="width:100%;height:100%;"></canvas>
<div id="chart-empty" class="hidden absolute inset-0 flex items-center justify-center text-xs text-neutral-400">No sales data for this period.</div>
<div id="chart-loading" class="absolute inset-0 flex items-center justify-center text-xs text-neutral-400">Loading chart…</div>
</div>
<div class="mt-3 flex items-center gap-2 text-xs text-neutral-500">
<span>Total distributed (all time):</span>
<span id="chart-total-badge" class="font-bold text-neutral-900"></span>
<span>QR codes</span>
</div>
</div>
{{-- KPI Grid --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="card p-3 border border-neutral-100 bg-neutral-50 shadow-xs border-l-4 border-l-red-500">
@@ -243,7 +313,7 @@
</div>
<div class="card p-3 border border-neutral-100 bg-neutral-50">
<div class="text-xs text-neutral-500 font-medium">Total Sold Codes</div>
<div class="text-lg font-bold text-neutral-950 mt-1" id="analytics-codes-sold">0</div>
<div class="text-lg font-bold text-neutral-950 mt-1" id="analytics-codes-sold2">0</div>
</div>
</div>
@@ -271,6 +341,7 @@
</div>
</div>
</div>
</form>
</div>
</div>
@@ -688,16 +759,230 @@
const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/${storeId}/analytics`);
if (!res.ok) return;
const data = res.body;
const data = res.body.data || {};
document.getElementById('analytics-owed').textContent = `₱${data.balance_due_pesos}`;
document.getElementById('analytics-settled').textContent = `₱${data.total_settled_pesos}`;
document.getElementById('analytics-commission').textContent = `₱${data.total_commission_pesos}`;
document.getElementById('analytics-codes-sold').textContent = data.total_sold_codes;
document.getElementById('analytics-codes-issued').textContent = data.total_issued_codes ?? '0';
document.getElementById('analytics-codes-sold2').textContent = data.total_sold_codes;
}
async function awaitChart() {
return new Promise(resolve => {
if (window.Chart) return resolve(window.Chart);
const check = setInterval(() => {
if (window.Chart) { clearInterval(check); resolve(window.Chart); }
}, 50);
});
}
// --- QR Distribution Chart ---
let distributionChartInstance = null;
let currentChartPeriod = 'daily';
async function fetchDistributionChart(storeId, period = 'daily') {
currentChartPeriod = period;
const loading = document.getElementById('chart-loading');
const empty = document.getElementById('chart-empty');
loading.classList.remove('hidden');
empty.classList.add('hidden');
// Update active period button
document.querySelectorAll('.chart-period-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.period === period);
});
const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/${storeId}/distribution-chart?period=${period}`);
loading.classList.add('hidden');
if (!res.ok) return;
const data = res.body.data || {};
const labels = data.labels ?? [];
const values = data.values ?? [];
document.getElementById('chart-total-badge').textContent = (data.total_distributed ?? 0).toLocaleString();
if (labels.length === 0) {
empty.classList.remove('hidden');
if (distributionChartInstance) { distributionChartInstance.destroy(); distributionChartInstance = null; }
return;
}
const Chart = await awaitChart();
const ctx = document.getElementById('distribution-chart').getContext('2d');
if (distributionChartInstance) distributionChartInstance.destroy();
distributionChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'QR Codes Distributed',
data: values,
backgroundColor: 'rgba(22, 163, 74, 0.15)',
borderColor: 'rgba(22, 163, 74, 1)',
borderWidth: 2,
borderRadius: 4,
hoverBackgroundColor: 'rgba(22, 163, 74, 0.3)',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.parsed.y} QR code${ctx.parsed.y !== 1 ? 's' : ''}`
}
}
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
y: { beginAtZero: true, ticks: { precision: 0, font: { size: 10 } }, grid: { color: '#f5f5f5' } }
}
}
});
}
// --- LGU Overall QR Distribution Chart ---
let overallChartInstance = null;
let currentOverallPeriod = 'daily';
async function fetchOverallDistributionChart(period = 'daily') {
currentOverallPeriod = period;
const loading = document.getElementById('overall-chart-loading');
const empty = document.getElementById('overall-chart-empty');
loading.classList.remove('hidden');
empty.classList.add('hidden');
// Update active period button
document.querySelectorAll('.overall-period-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.overallPeriod === period);
});
const params = new URLSearchParams();
params.set('period', period);
const lguVal = document.getElementById('chart-filter-lgu').value;
const saVal = document.getElementById('chart-filter-service-area').value;
const brgyVal = document.getElementById('chart-filter-barangay').value;
if (lguVal) params.set('tenant_id', lguVal);
if (saVal) params.set('service_area_id', saVal);
if (brgyVal) params.set('barangay_id', brgyVal);
const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/overall-chart?${params.toString()}`);
loading.classList.add('hidden');
if (!res.ok) return;
const data = res.body.data || {};
const labels = data.labels ?? [];
const values = data.values ?? [];
document.getElementById('overall-chart-total').textContent = (data.total_distributed ?? 0).toLocaleString();
if (labels.length === 0) {
empty.classList.remove('hidden');
if (overallChartInstance) { overallChartInstance.destroy(); overallChartInstance = null; }
return;
}
const Chart = await awaitChart();
const ctx = document.getElementById('overall-distribution-chart').getContext('2d');
if (overallChartInstance) overallChartInstance.destroy();
overallChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Overall QR Codes Distributed',
data: values,
backgroundColor: 'rgba(34, 197, 94, 0.2)',
borderColor: 'rgba(22, 163, 74, 1)',
borderWidth: 2,
borderRadius: 4,
hoverBackgroundColor: 'rgba(22, 163, 74, 0.4)',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.parsed.y} QR code${ctx.parsed.y !== 1 ? 's' : ''}`
}
}
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
y: { beginAtZero: true, ticks: { precision: 0, font: { size: 10 } }, grid: { color: '#f5f5f5' } }
}
}
});
}
async function populateOverallFilters() {
const user = window.Verde.getUser();
const isSuperAdmin = user?.role === 'super_admin';
const lguSelect = document.getElementById('chart-filter-lgu');
const saSelect = document.getElementById('chart-filter-service-area');
const brgySelect = document.getElementById('chart-filter-barangay');
if (isSuperAdmin) {
lguSelect.classList.remove('hidden');
const res = await window.Verde.apiFetch('/api/v1/super-admin/tenants?per_page=100');
if (res.ok && res.body?.data) {
lguSelect.innerHTML = '<option value="">All LGUs</option>' +
res.body.data.map(tenant => `<option value="${tenant.id}">${window.Verde.escapeHtml(tenant.name)}</option>`).join('');
}
}
async function loadGeoFilters(tenantId = null) {
let saUrl = '/api/v1/service-areas';
if (tenantId) saUrl += `?tenant_id=${tenantId}`;
const saRes = await window.Verde.apiFetch(saUrl);
if (saRes.ok && saRes.body?.data) {
saSelect.innerHTML = '<option value="">All Service Areas</option>' +
saRes.body.data.map(sa => `<option value="${sa.id}">${window.Verde.escapeHtml(sa.name)}</option>`).join('');
}
let brgyUrl = '/api/v1/geo/barangays';
if (tenantId) brgyUrl += `?tenant_id=${tenantId}`;
const brgyRes = await window.Verde.apiFetch(brgyUrl);
if (brgyRes.ok && brgyRes.body?.data) {
brgySelect.innerHTML = '<option value="">All Barangays</option>' +
brgyRes.body.data.map(b => `<option value="${b.id}">${window.Verde.escapeHtml(b.name)}</option>`).join('');
}
}
await loadGeoFilters();
lguSelect.addEventListener('change', async () => {
const tenantId = lguSelect.value;
saSelect.innerHTML = '<option value="">Loading Service Areas...</option>';
brgySelect.innerHTML = '<option value="">Loading Barangays...</option>';
await loadGeoFilters(tenantId);
fetchOverallDistributionChart(currentOverallPeriod);
});
saSelect.addEventListener('change', () => {
fetchOverallDistributionChart(currentOverallPeriod);
});
brgySelect.addEventListener('change', () => {
fetchOverallDistributionChart(currentOverallPeriod);
});
}
// Tab switching (edit mode only)
function switchTab(name) {
document.querySelectorAll('#tab-details, #tab-inventory, #tab-inventory-history, #tab-analytics').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('#tab-details, #tab-inventory, #tab-inventory-history, #tab-settlements, #tab-analytics').forEach(p => p.classList.add('hidden'));
document.getElementById(`tab-${name}`).classList.remove('hidden');
document.querySelectorAll('.tab-btn').forEach(b => {
const active = b.dataset.tab === name;
@@ -719,6 +1004,11 @@
salesPage = 1;
fetchAnalytics(editingStoreId);
fetchSales(editingStoreId, 1);
fetchDistributionChart(editingStoreId, 'daily');
// Wire period buttons
document.querySelectorAll('.chart-period-btn').forEach(btn => {
btn.onclick = () => fetchDistributionChart(editingStoreId, btn.dataset.period);
});
}
}
@@ -1206,5 +1496,12 @@
loadOwners();
load();
// Init overall chart and wire buttons
populateOverallFilters();
fetchOverallDistributionChart('daily');
document.querySelectorAll('.overall-period-btn').forEach(btn => {
btn.onclick = () => fetchOverallDistributionChart(btn.dataset.overallPeriod);
});
</script>
@endsection

View File

@@ -5,7 +5,7 @@
<header class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Reports</h2>
<p class="mt-1 text-sm text-neutral-500">Daily collection, route performance, store sales, compliance exports.</p>
<p class="mt-1 text-sm text-neutral-500">Daily collection, route performance, store sales, compliance exports, and team analytics.</p>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 mr-2">
@@ -39,10 +39,11 @@
{{-- Tab nav --}}
<div class="mb-6 flex gap-1 border-b border-neutral-200">
@foreach ([
['key' => 'daily', 'label' => 'Daily Collection'],
['key' => 'trips', 'label' => 'Trip Performance'],
['key' => 'sales', 'label' => 'Store Sales'],
['key' => 'daily', 'label' => 'Daily Collection'],
['key' => 'trips', 'label' => 'Trip Performance'],
['key' => 'sales', 'label' => 'Store Sales'],
['key' => 'compliance', 'label' => 'Compliance Export'],
['key' => 'teams', 'label' => '📊 Teams'],
] as $tab)
<button data-tab="{{ $tab['key'] }}"
class="report-tab px-4 py-2 text-sm font-medium text-neutral-500 transition hover:text-neutral-900 border-b-2 border-transparent">
@@ -120,15 +121,154 @@
</div>
</div>
</section>
{{-- Teams --}}
<section data-panel="teams" class="report-panel hidden">
{{-- Filter bar --}}
<div class="card-padded mb-4 flex flex-wrap items-end gap-3">
<div><label class="form-label">From</label><input id="teams-from" type="date" class="form-input"></div>
<div><label class="form-label">To</label><input id="teams-to" type="date" class="form-input"></div>
<div id="teams-lgu-wrap" class="hidden flex items-center gap-2">
<label class="form-label whitespace-nowrap">LGU</label>
<select id="teams-lgu" class="form-input py-1 text-sm">
<option value="">All LGUs</option>
</select>
</div>
<button id="teams-load" class="btn-primary">Load Teams</button>
</div>
{{-- Summary KPI cards --}}
<div class="grid grid-cols-2 gap-3 md:grid-cols-4 mb-4">
<div class="card p-5">
<div class="text-xs uppercase tracking-wider text-neutral-500">Teams</div>
<div class="mt-2 text-2xl font-semibold" id="teams-kpi-total"></div>
</div>
<div class="card p-5">
<div class="text-xs uppercase tracking-wider text-neutral-500">Total Trips</div>
<div class="mt-2 text-2xl font-semibold" id="teams-kpi-trips"></div>
</div>
<div class="card p-5">
<div class="text-xs uppercase tracking-wider text-neutral-500">Total Scans</div>
<div class="mt-2 text-2xl font-semibold" id="teams-kpi-scans"></div>
</div>
<div class="card p-5">
<div class="text-xs uppercase tracking-wider text-neutral-500">Total Events</div>
<div class="mt-2 text-2xl font-semibold" id="teams-kpi-events"></div>
</div>
</div>
{{-- Leaderboard table --}}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th class="w-12">#</th>
<th>Team</th>
<th>Driver</th>
<th>Status</th>
<th>Trips</th>
<th>Done %</th>
<th>Scans</th>
<th>Avg Load</th>
<th>Events</th>
<th></th>
</tr>
</thead>
<tbody id="teams-rows">
<tr><td colspan="10" class="py-10 text-center text-sm text-neutral-400">Pick a date range and load.</td></tr>
</tbody>
</table>
</div>
<div id="teams-pagination" class="mt-4"></div>
</section>
</div>
{{-- Team Profile Drawer --}}
<div id="team-drawer"
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-3xl flex-col bg-white shadow-2xl transition-transform duration-300"
style="transform: translateX(100%)">
{{-- Drawer Header --}}
<div class="flex shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-4">
<div>
<h3 id="drawer-team-name" class="text-lg font-semibold text-neutral-900"></h3>
<p id="drawer-team-meta" class="mt-0.5 text-sm text-neutral-500"></p>
</div>
<button id="drawer-close" class="rounded-lg p-2 text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-700" title="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{-- Scrollable body --}}
<div class="flex-1 overflow-y-auto px-6 py-5 space-y-7">
{{-- KPI Cards --}}
<div>
<h4 class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Performance Overview</h4>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4" id="drawer-kpis"></div>
</div>
{{-- Charts --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="card p-4">
<div class="mb-3 text-sm font-semibold text-neutral-700">Daily Scans</div>
<canvas id="chart-daily" height="160"></canvas>
</div>
<div class="card p-4">
<div class="mb-3 text-sm font-semibold text-neutral-700">Weekly Scans</div>
<canvas id="chart-weekly" height="160"></canvas>
</div>
</div>
{{-- Trip History --}}
<div>
<h4 class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Trip History</h4>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Trip #</th><th>Date</th><th>Status</th>
<th>Scans</th><th>Load (kg)</th><th>Duration</th>
</tr>
</thead>
<tbody id="drawer-trips">
<tr><td colspan="6" class="py-6 text-center text-sm text-neutral-400">Loading…</td></tr>
</tbody>
</table>
</div>
<div id="drawer-trips-pag" class="mt-3"></div>
</div>
{{-- Event Log --}}
<div>
<h4 class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Event Log <span class="ml-1 text-neutral-300">(last 200)</span></h4>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>Time</th><th>Trip</th><th>Event</th><th>Notes</th></tr>
</thead>
<tbody id="drawer-events">
<tr><td colspan="4" class="py-6 text-center text-sm text-neutral-400">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{{-- Drawer backdrop --}}
<div id="drawer-backdrop" class="fixed inset-0 z-40 hidden bg-black/40 backdrop-blur-sm transition-opacity"></div>
<style>
.report-tab.is-active { color: rgb(34 83 47); border-bottom-color: rgb(45 131 65); }
#lgu-map { z-index: 5; }
#team-drawer { transition: transform 0.3s cubic-bezier(.4,0,.2,1); }
#team-drawer.is-open { transform: translateX(0) !important; }
#drawer-backdrop.is-open { display: block; opacity: 1; }
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script type="module">
function pesos(c) { return ((c ?? 0) / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
@@ -145,7 +285,12 @@
let lguBoundary = null;
let lguData = [];
let currentPages = { daily: 1, trips: 1, sales: 1 };
let currentPages = { daily: 1, trips: 1, sales: 1, teams: 1 };
let teamsLguId = null;
let drawerDailyChart = null;
let drawerWeeklyChart = null;
let drawerCurrentTeam = null;
let drawerTripsMeta = null;
async function initLguSelector() {
if (!isSuperAdmin) return;
@@ -158,6 +303,16 @@
selector.innerHTML = '<option value="">All (Default)</option>' +
lguData.map(l => `<option value="${l.db_id}">${window.Verde.escapeHtml(l.name)}</option>`).join('');
// Populate teams-specific LGU selector
const teamsLguSel = document.getElementById('teams-lgu');
document.getElementById('teams-lgu-wrap').classList.remove('hidden');
teamsLguSel.innerHTML = '<option value="">All LGUs</option>' +
lguData.map(l => `<option value="${l.db_id}">${window.Verde.escapeHtml(l.name)}</option>`).join('');
teamsLguSel.addEventListener('change', (e) => {
teamsLguId = e.target.value || null;
loadTeams(1);
});
selector.addEventListener('change', (e) => {
currentLguId = e.target.value || null;
updateLguMap();
@@ -224,6 +379,7 @@
if (activeTab === 'daily') loadDaily(1);
else if (activeTab === 'trips') loadTrips(1);
else if (activeTab === 'sales') loadSales(1);
else if (activeTab === 'teams') loadTeams(1);
}
async function loadDaily(page = 1) {
@@ -352,8 +508,307 @@
document.getElementById('daily-load').addEventListener('click', () => loadDaily(1));
document.getElementById('trips-load').addEventListener('click', () => loadTrips(1));
document.getElementById('sales-load').addEventListener('click', () => loadSales(1));
document.getElementById('teams-load').addEventListener('click', () => loadTeams(1));
document.getElementById('filter-per-page').addEventListener('change', () => loadAll());
// ─────────────────────────────────────────────
// TEAMS LEADERBOARD
// ─────────────────────────────────────────────
async function loadTeams(page = 1) {
currentPages.teams = page;
const from = document.getElementById('teams-from').value;
const to = document.getElementById('teams-to').value;
const perPage = document.getElementById('filter-per-page').value;
let url = `/api/v1/admin/reports/teams/leaderboard?from=${from}&to=${to}&page=${page}&per_page=${perPage}`;
if (teamsLguId) url += `&tenant_id=${teamsLguId}`;
const res = await window.Verde.apiFetch(url);
if (!res.ok) { window.Verde.toast('Failed to load teams', 'error'); return; }
const rows = res.body.data ?? [];
const meta = res.body.meta ?? {};
// Summary KPIs (aggregate from page rows)
const totalTrips = rows.reduce((s, r) => s + (r.total_trips ?? 0), 0);
const totalScans = rows.reduce((s, r) => s + (r.total_scans ?? 0), 0);
const totalEvents = rows.reduce((s, r) => s + (r.event_count ?? 0), 0);
document.getElementById('teams-kpi-total').textContent = (meta.total ?? rows.length).toLocaleString();
document.getElementById('teams-kpi-trips').textContent = totalTrips.toLocaleString();
document.getElementById('teams-kpi-scans').textContent = totalScans.toLocaleString();
document.getElementById('teams-kpi-events').textContent = totalEvents.toLocaleString();
const tbody = document.getElementById('teams-rows');
const pagEl = document.getElementById('teams-pagination');
const offset = ((meta.page ?? 1) - 1) * (meta.per_page ?? 50);
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" class="py-10 text-center text-sm text-neutral-400">No teams found for this date range.</td></tr>`;
pagEl.innerHTML = '';
return;
}
window.Verde.renderPagination(pagEl, meta, (p) => loadTeams(p));
const statusBadge = (s) => ({
active: '<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Active</span>',
inactive: '<span class="inline-flex items-center rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-500">Inactive</span>',
standby: '<span class="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20">Standby</span>',
}[s] ?? `<span class="text-xs text-neutral-400">${s}</span>`);
tbody.innerHTML = rows.map((r, i) => `
<tr class="hover:bg-neutral-50 cursor-pointer" data-team-uuid="${r.team_uuid}" data-team-name="${window.Verde.escapeHtml(r.team_name)}">
<td class="text-center font-mono text-xs text-neutral-400">${offset + i + 1}</td>
<td class="font-semibold">${window.Verde.escapeHtml(r.team_name)}</td>
<td class="text-sm text-neutral-600">${window.Verde.escapeHtml(r.driver_name ?? '—')}</td>
<td>${statusBadge(r.status)}</td>
<td>${r.total_trips.toLocaleString()}</td>
<td>${r.completion_rate_percent}%</td>
<td class="font-semibold">${r.total_scans.toLocaleString()}</td>
<td>${r.avg_load_per_trip_kg} kg</td>
<td>${r.event_count.toLocaleString()}</td>
<td>
<button class="btn-ghost py-1 px-2 text-xs team-view-btn"
data-uuid="${r.team_uuid}"
data-name="${window.Verde.escapeHtml(r.team_name)}"
data-from="${from}" data-to="${to}">
View
</button>
</td>
</tr>
`).join('');
// Attach row and button click handlers
tbody.querySelectorAll('.team-view-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
openTeamDrawer(btn.dataset.uuid, btn.dataset.name, btn.dataset.from, btn.dataset.to);
});
});
}
// ─────────────────────────────────────────────
// TEAM PROFILE DRAWER
// ─────────────────────────────────────────────
const EVENT_LABELS = {
trip_started: { label: 'Trip Started', css: 'bg-green-50 text-green-700 ring-green-600/20' },
arrived_at_stop: { label: 'At Stop', css: 'bg-blue-50 text-blue-700 ring-blue-600/20' },
collection_started: { label: 'Collection Start', css: 'bg-blue-50 text-blue-600 ring-blue-600/20' },
qr_scanned: { label: 'QR Scanned', css: 'bg-neutral-100 text-neutral-600' },
collection_completed: { label: 'Collection Done', css: 'bg-blue-50 text-blue-700 ring-blue-600/20' },
departed_stop: { label: 'Departed Stop', css: 'bg-neutral-50 text-neutral-500' },
stop_skipped: { label: 'Stop Skipped', css: 'bg-amber-50 text-amber-700 ring-amber-600/20' },
truck_full_warning: { label: 'Truck Full', css: 'bg-orange-50 text-orange-700 ring-orange-600/20' },
arrived_at_dumpsite: { label: 'At Dumpsite', css: 'bg-teal-50 text-teal-700 ring-teal-600/20' },
load_released: { label: 'Load Released', css: 'bg-teal-50 text-teal-600 ring-teal-600/20' },
departed_dumpsite: { label: 'Left Dumpsite', css: 'bg-neutral-50 text-neutral-400' },
trip_completed: { label: '✓ Completed', css: 'bg-green-100 text-green-800 ring-green-700/20' },
incident_reported: { label: '⚠ Incident', css: 'bg-red-50 text-red-700 ring-red-600/20' },
breakdown: { label: '🔧 Breakdown', css: 'bg-red-100 text-red-800 ring-red-700/20' },
detour_to_dumpsite: { label: 'Detour', css: 'bg-amber-50 text-amber-700 ring-amber-600/20' },
resumed_from_detour: { label: 'Resumed', css: 'bg-green-50 text-green-600 ring-green-600/20' },
continuation_created: { label: 'Continuation', css: 'bg-neutral-50 text-neutral-600' },
};
function eventBadge(type) {
const def = EVENT_LABELS[type] ?? { label: type, css: 'bg-neutral-100 text-neutral-500' };
return `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${def.css}">${def.label}</span>`;
}
function destroyDrawerCharts() {
if (drawerDailyChart) { drawerDailyChart.destroy(); drawerDailyChart = null; }
if (drawerWeeklyChart) { drawerWeeklyChart.destroy(); drawerWeeklyChart = null; }
}
async function openTeamDrawer(uuid, name, from, to) {
drawerCurrentTeam = { uuid, name, from, to };
destroyDrawerCharts();
// Show drawer
document.getElementById('team-drawer').classList.add('is-open');
document.getElementById('drawer-backdrop').classList.add('is-open');
document.getElementById('drawer-team-name').textContent = name;
document.getElementById('drawer-team-meta').textContent = `${from} → ${to}`;
document.getElementById('drawer-kpis').innerHTML = renderKpiSkeleton();
document.getElementById('drawer-trips').innerHTML = `<tr><td colspan="6" class="py-6 text-center text-sm text-neutral-400">Loading…</td></tr>`;
document.getElementById('drawer-events').innerHTML = `<tr><td colspan="4" class="py-6 text-center text-sm text-neutral-400">Loading…</td></tr>`;
let url = `/api/v1/admin/reports/teams/${uuid}/profile?from=${from}&to=${to}`;
if (teamsLguId) url += `&tenant_id=${teamsLguId}`;
const res = await window.Verde.apiFetch(url);
if (!res.ok) { window.Verde.toast('Failed to load team profile', 'error'); return; }
const d = res.body.data;
// --- Team meta ---
const t = d.team;
const meta = [
t.driver ? `Driver: ${t.driver.name}` : null,
t.scanner ? `Scanner: ${t.scanner.name}` : null,
t.truck ? `Truck: ${t.truck.plate}` : null,
t.area ? `Area: ${t.area}` : null,
].filter(Boolean).join(' · ');
document.getElementById('drawer-team-meta').textContent = `${meta} · ${from} → ${to}`;
// --- KPI Cards ---
const kpis = d.kpis;
const kpiDefs = [
{ label: 'Total Trips', value: kpis.total_trips.toLocaleString() },
{ label: 'Completed', value: kpis.completed_trips.toLocaleString() },
{ label: 'Completion', value: kpis.completion_rate_percent + '%' },
{ label: 'On-Time', value: kpis.on_time_rate_percent + '%' },
{ label: 'Total Scans', value: kpis.total_scans.toLocaleString() },
{ label: 'Weight (kg)', value: kpis.total_weight_kg.toLocaleString() },
{ label: 'Avg Load', value: kpis.avg_load_per_trip_kg + ' kg' },
{ label: 'Incidents', value: kpis.incident_count.toLocaleString() },
{ label: 'Stops Skipped', value: kpis.stops_skipped_count.toLocaleString() },
{ label: 'Detours', value: kpis.detours_count.toLocaleString() },
{ label: 'Breakdowns', value: kpis.breakdowns_count.toLocaleString() },
{ label: 'Truck Full', value: kpis.truck_full_count.toLocaleString() },
];
document.getElementById('drawer-kpis').innerHTML = kpiDefs.map(k => `
<div class="card p-4">
<div class="text-xs uppercase tracking-wider text-neutral-500">${k.label}</div>
<div class="mt-1.5 text-xl font-semibold text-neutral-900">${k.value}</div>
</div>
`).join('');
// --- Charts ---
renderDailyChart(d.daily_scans ?? []);
renderWeeklyChart(d.weekly_scans ?? []);
// --- Trips table ---
drawerTripsMeta = d.trips.meta;
renderDrawerTrips(d.trips.data ?? []);
// --- Events table ---
renderDrawerEvents(d.events ?? []);
}
function renderKpiSkeleton() {
return Array(12).fill('').map(() =>
`<div class="card p-4 animate-pulse">
<div class="h-2 w-16 rounded bg-neutral-200 mb-2"></div>
<div class="h-5 w-10 rounded bg-neutral-200"></div>
</div>`
).join('');
}
function renderDailyChart(data) {
const ctx = document.getElementById('chart-daily');
if (!ctx) return;
if (drawerDailyChart) { drawerDailyChart.destroy(); drawerDailyChart = null; }
drawerDailyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.date),
datasets: [{
label: 'Scans',
data: data.map(d => d.scans),
backgroundColor: 'rgba(45,131,65,0.75)',
borderColor: '#2d8341',
borderWidth: 1,
borderRadius: 3,
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
}
});
}
function renderWeeklyChart(data) {
const ctx = document.getElementById('chart-weekly');
if (!ctx) return;
if (drawerWeeklyChart) { drawerWeeklyChart.destroy(); drawerWeeklyChart = null; }
drawerWeeklyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.week_start),
datasets: [{
label: 'Scans',
data: data.map(d => d.scans),
backgroundColor: 'rgba(45,131,65,0.6)',
borderColor: '#2d8341',
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
}
});
}
function renderDrawerTrips(rows) {
const tbody = document.getElementById('drawer-trips');
const pagEl = document.getElementById('drawer-trips-pag');
if (rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="py-6 text-center text-sm text-neutral-400">No trips in this range.</td></tr>`;
pagEl.innerHTML = '';
return;
}
if (drawerTripsMeta) {
window.Verde.renderPagination(pagEl, drawerTripsMeta, async (p) => {
const { uuid, from, to } = drawerCurrentTeam;
let url = `/api/v1/admin/reports/teams/${uuid}/profile?from=${from}&to=${to}&trip_page=${p}`;
if (teamsLguId) url += `&tenant_id=${teamsLguId}`;
const res = await window.Verde.apiFetch(url);
if (!res.ok) return;
drawerTripsMeta = res.body.data.trips.meta;
renderDrawerTrips(res.body.data.trips.data);
});
}
const tripStatusBadge = (s) => ({
completed: '<span class="inline-flex items-center rounded-full bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700">Completed</span>',
handed_off: '<span class="inline-flex items-center rounded-full bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-600">Handed Off</span>',
cancelled: '<span class="inline-flex items-center rounded-full bg-red-50 px-1.5 py-0.5 text-xs font-medium text-red-600">Cancelled</span>',
in_progress: '<span class="inline-flex items-center rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-medium text-blue-700">In Progress</span>',
at_dumpsite: '<span class="inline-flex items-center rounded-full bg-teal-50 px-1.5 py-0.5 text-xs font-medium text-teal-700">At Dumpsite</span>',
scheduled: '<span class="inline-flex items-center rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs font-medium text-neutral-500">Scheduled</span>',
}[s] ?? `<span class="text-xs">${s}</span>`);
tbody.innerHTML = rows.map(r => `
<tr>
<td class="font-mono text-xs">${window.Verde.escapeHtml(r.trip_number ?? '—')}</td>
<td>${window.Verde.escapeHtml(r.scheduled_date ?? '—')}</td>
<td>${tripStatusBadge(r.status)}</td>
<td>${(r.scans_count ?? 0).toLocaleString()}</td>
<td>${(r.total_load_kg ?? 0).toLocaleString()}</td>
<td>${r.duration_minutes != null ? r.duration_minutes + 'm' : '—'}</td>
</tr>
`).join('');
}
function renderDrawerEvents(events) {
const tbody = document.getElementById('drawer-events');
if (events.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" class="py-6 text-center text-sm text-neutral-400">No events recorded.</td></tr>`;
return;
}
tbody.innerHTML = events.map(e => `
<tr>
<td class="whitespace-nowrap text-xs text-neutral-500">${e.event_at ? new Date(e.event_at).toLocaleString() : '—'}</td>
<td class="font-mono text-xs">${window.Verde.escapeHtml(e.trip_number ?? '—')}</td>
<td>${eventBadge(e.event_type)}</td>
<td class="text-xs text-neutral-500 max-w-xs truncate">${window.Verde.escapeHtml(e.notes ?? '')}</td>
</tr>
`).join('');
}
function closeTeamDrawer() {
document.getElementById('team-drawer').classList.remove('is-open');
document.getElementById('drawer-backdrop').classList.remove('is-open');
destroyDrawerCharts();
}
document.getElementById('drawer-close').addEventListener('click', closeTeamDrawer);
document.getElementById('drawer-backdrop').addEventListener('click', closeTeamDrawer);
document.getElementById('comp-download').addEventListener('click', async () => {
const from = document.getElementById('comp-from').value;
const to = document.getElementById('comp-to').value;
@@ -383,5 +838,44 @@
initLguSelector();
loadDaily();
// Sync teams date inputs with global date range defaults
document.getElementById('teams-from').value = monthAgo;
document.getElementById('teams-to').value = today;
// ─────────────────────────────────────────────────────────────
// Deep-link boot: /admin/reports?tab=teams&team=<uuid>
// Activated when clicking "Analytics ↗" from the Teams card.
// ─────────────────────────────────────────────────────────────
(async () => {
const params = new URLSearchParams(window.location.search);
const tabParam = params.get('tab');
const teamUuid = params.get('team');
if (tabParam !== 'teams') return;
// Activate the Teams tab first
activateTab('teams');
// Load the leaderboard (so the user sees context behind the drawer)
await loadTeams(1);
// If a specific team UUID was requested, open its drawer immediately
if (teamUuid) {
const from = document.getElementById('teams-from').value;
const to = document.getElementById('teams-to').value;
// Try to find the team name from the leaderboard rows already rendered
const matchRow = document.querySelector(`[data-team-uuid="${CSS.escape(teamUuid)}"]`);
const name = matchRow?.querySelector('td:nth-child(2)')?.textContent?.trim()
?? teamUuid;
openTeamDrawer(teamUuid, name, from, to);
}
// Clean up the URL without a page reload (avoids re-triggering on refresh)
const cleanUrl = window.location.pathname;
window.history.replaceState({}, '', cleanUrl);
})();
</script>
@endsection

View File

@@ -72,7 +72,13 @@
</div>
<!-- Right Column map -->
<div class="lg:col-span-7 flex flex-col">
<label class="form-label mb-1">Select Barangays on Map</label>
<div class="flex items-center justify-between mb-2">
<label class="form-label mb-0">Select Barangays or Fetch Geofence</label>
<div class="flex gap-1.5 items-center">
<input type="text" id="geofence-search-input" placeholder="e.g. Baesa" class="form-input text-xs py-1 px-2 w-48">
<button type="button" id="geofence-search-btn" class="btn-primary text-xs py-1 px-2.5">Fetch Geofence</button>
</div>
</div>
<div id="map-picker" class="w-full rounded-lg border border-neutral-200 bg-neutral-50 h-[380px] min-h-[300px]"></div>
<div class="mt-2 text-xs text-neutral-500 flex items-center justify-between">
<span>Selected: <strong id="selected-count" class="font-bold text-neutral-900">0</strong> Barangays</span>
@@ -88,7 +94,117 @@
</div>
</div>
{{-- Add Household Slide-over modal --}}
<div id="household-modal" class="hidden" style="z-index: 40;">
<div class="slide-over-mask" data-close></div>
<div class="slide-over-panel translate-x-0 p-6" style="max-width: 1000px; width: 90%;">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-base font-semibold text-neutral-900">Add Household to Service Area</h3>
<button data-close class="text-neutral-400 hover:text-neutral-600">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<form id="household-form" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Left Column fields -->
<div class="lg:col-span-5 space-y-4">
<div>
<label class="form-label">Resident Registration Mode</label>
<div class="flex gap-4 mt-2">
<label class="inline-flex items-center cursor-pointer">
<input type="radio" name="resident_type" value="new" checked class="form-radio text-green-600 focus:ring-green-500">
<span class="ml-2 text-sm text-neutral-700">New Resident</span>
</label>
<label class="inline-flex items-center cursor-pointer">
<input type="radio" name="resident_type" value="existing" class="form-radio text-green-600 focus:ring-green-500">
<span class="ml-2 text-sm text-neutral-700">Existing Resident</span>
</label>
</div>
</div>
<!-- New Resident fields -->
<div id="household-new-resident-fields" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="form-label">First Name</label>
<input name="first_name" class="form-input" placeholder="John">
</div>
<div>
<label class="form-label">Last Name</label>
<input name="last_name" class="form-input" placeholder="Doe">
</div>
</div>
<div>
<label class="form-label">Email</label>
<input type="email" name="email" class="form-input" placeholder="john.doe@example.com">
</div>
<div>
<label class="form-label">Phone</label>
<input name="phone" class="form-input" placeholder="+639000000000">
</div>
<div>
<label class="form-label">Password (Optional)</label>
<input type="password" name="password" class="form-input" placeholder="Auto-generated if blank">
</div>
</div>
<!-- Existing Resident fields (hidden by default) -->
<div id="household-existing-resident-fields" class="hidden">
<label class="form-label">Select Resident</label>
<select name="head_user_id" class="form-select w-full">
<option value="">Loading residents…</option>
</select>
</div>
<hr class="border-neutral-100">
<!-- Household common fields -->
<div>
<label class="form-label">Address Line</label>
<input name="address_line" required class="form-input" placeholder="Block 1 Lot 2, Street Name">
</div>
<div>
<label class="form-label">Barangay</label>
<select name="barangay_id" id="household-barangay-select" required class="form-select w-full">
<option value="">Select a Barangay</option>
</select>
</div>
<div>
<label class="form-label">Household Size</label>
<input type="number" name="household_size" required min="1" max="50" value="1" class="form-input">
</div>
<div>
<label class="form-label">Proof of Residency Document</label>
<input type="file" name="proof" class="form-input" accept=".jpg,.jpeg,.png,.pdf">
<p class="text-xs text-neutral-400 mt-1">Acceptable formats: JPG, JPEG, PNG, PDF (Max 8MB).</p>
</div>
<input type="hidden" name="lat" id="household-lat">
<input type="hidden" name="lng" id="household-lng">
</div>
<!-- Right Column map -->
<div class="lg:col-span-7 flex flex-col">
<label class="form-label mb-1">Pin Location on Map</label>
<div id="household-map-picker" class="w-full rounded-lg border border-neutral-200 bg-neutral-50 h-[380px] min-h-[300px]" style="z-index: 1;"></div>
<div class="mt-2 text-xs text-neutral-500">
Select a Barangay first to view boundaries, then click inside to pin the exact household coordinate.
</div>
</div>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-neutral-100">
<button type="button" data-close class="btn-ghost">Cancel</button>
<button type="submit" class="btn-primary">Save Household</button>
</div>
</form>
</div>
</div>
<link rel="stylesheet" href="https://unpkg.com/@geoman-io/leaflet-geoman-free@latest/dist/leaflet-geoman.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/@geoman-io/leaflet-geoman-free@latest/dist/leaflet-geoman.min.js"></script>
<script type="module">
const rows = document.getElementById('rows');
const modal = document.getElementById('form-modal');
@@ -99,6 +215,7 @@
let tenantBoundaryLayer = null;
let allBarangays = []; // cache of LGU barangays
let editingAreaId = null; // null for create, string uuid for edit
let drawnGeofenceLayer = null;
function awaitLeaflet() {
return new Promise(resolve => {
@@ -116,6 +233,29 @@
maxZoom: 19,
attribution: '&copy; OpenStreetMap'
}).addTo(leafletMap);
// Initialize Leaflet Geoman Controls
leafletMap.pm.addControls({
position: 'topleft',
drawMarker: false,
drawCircleMarker: false,
drawPolyline: false,
drawRectangle: false,
drawCircle: false,
drawPolygon: true,
editMode: true,
dragMode: true,
cutPolygon: false,
removalMode: true
});
leafletMap.on('pm:create', (e) => {
if (drawnGeofenceLayer) {
leafletMap.removeLayer(drawnGeofenceLayer);
}
drawnGeofenceLayer = e.layer;
drawnGeofenceLayer.pm.enable();
});
}
async function drawLguBoundary(L, boundary) {
@@ -234,12 +374,17 @@
<td>${a.barangay_count ?? 0}</td>
<td>${badge(a.status)}</td>
<td class="text-right">
<button data-id="${a.id}" data-action="edit" class="btn-ghost px-3 py-1 text-xs text-blue-600 hover:text-blue-800">Edit</button>
<button data-id="${a.id}" data-action="add-household" class="btn-ghost px-3 py-1 text-xs text-green-600 hover:text-green-800">Add Household</button>
<button data-id="${a.id}" data-action="edit" class="btn-ghost px-3 py-1 text-xs text-blue-600 hover:text-blue-800 ml-1">Edit</button>
<button data-id="${a.id}" data-action="delete" class="btn-ghost px-3 py-1 text-xs text-red-600 hover:text-red-800 ml-1">Delete</button>
</td>
</tr>
`).join('');
rows.querySelectorAll('[data-action="add-household"]').forEach(b => {
b.addEventListener('click', () => onAddHousehold(b.dataset.id));
});
rows.querySelectorAll('[data-action="edit"]').forEach(b => {
b.addEventListener('click', () => onEdit(b.dataset.id));
});
@@ -254,6 +399,11 @@
document.getElementById('modal-title').textContent = 'Edit service area';
selectedBarangays.clear();
document.getElementById('selected-count').textContent = 0;
if (drawnGeofenceLayer) {
leafletMap.removeLayer(drawnGeofenceLayer);
drawnGeofenceLayer = null;
}
document.getElementById('geofence-search-input').value = '';
modal.classList.remove('hidden');
await initMap();
@@ -274,6 +424,20 @@
(area.barangays ?? []).forEach(b => selectedBarangays.add(b.id));
document.getElementById('selected-count').textContent = selectedBarangays.size;
if (area.boundary && area.boundary.length > 0) {
const L = window.L;
const points = area.boundary.map(p => [p.lat, p.lng]);
drawnGeofenceLayer = L.polygon(points, {
color: '#16a34a',
weight: 2.5,
fillColor: '#22c55e',
fillOpacity: 0.3
}).addTo(leafletMap);
leafletMap.fitBounds(drawnGeofenceLayer.getBounds());
drawnGeofenceLayer.pm.enable();
}
setTimeout(async () => {
leafletMap.invalidateSize();
const L = window.L;
@@ -300,6 +464,11 @@
document.getElementById('create-form').reset();
selectedBarangays.clear();
document.getElementById('selected-count').textContent = 0;
if (drawnGeofenceLayer) {
leafletMap.removeLayer(drawnGeofenceLayer);
drawnGeofenceLayer = null;
}
document.getElementById('geofence-search-input').value = '';
modal.classList.remove('hidden');
await initMap();
@@ -317,7 +486,38 @@
}, 100);
});
modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', () => modal.classList.add('hidden')));
modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', () => {
modal.classList.add('hidden');
if (drawnGeofenceLayer) {
leafletMap.removeLayer(drawnGeofenceLayer);
drawnGeofenceLayer = null;
}
}));
function getGeofencePoints() {
if (!drawnGeofenceLayer) return null;
let layerToExtract = drawnGeofenceLayer;
if (drawnGeofenceLayer.getLayers) {
const layers = drawnGeofenceLayer.getLayers();
if (layers.length > 0) {
layerToExtract = layers[0];
} else {
return null;
}
}
if (typeof layerToExtract.getLatLngs === 'function') {
const latlngs = layerToExtract.getLatLngs();
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
return ring.map(p => ({
lat: p.lat,
lng: p.lng
}));
}
return null;
}
document.getElementById('create-form').addEventListener('submit', async (e) => {
e.preventDefault();
@@ -325,6 +525,11 @@
const payload = Object.fromEntries(fd.entries());
payload.barangay_ids = Array.from(selectedBarangays);
const boundary = getGeofencePoints();
if (boundary) {
payload.boundary = boundary;
}
const url = editingAreaId ? `/api/v1/service-areas/${editingAreaId}` : '/api/v1/service-areas';
const method = editingAreaId ? 'PATCH' : 'POST';
@@ -336,6 +541,291 @@
window.Verde.toast(editingAreaId ? 'Service area updated' : 'Service area created', 'success');
modal.classList.add('hidden');
e.target.reset();
if (drawnGeofenceLayer) {
leafletMap.removeLayer(drawnGeofenceLayer);
drawnGeofenceLayer = null;
}
load();
} else {
window.Verde.toast(res.body?.message ?? 'Save failed', 'error');
}
});
document.getElementById('geofence-search-btn')?.addEventListener('click', async () => {
const query = document.getElementById('geofence-search-input').value.trim();
if (!query) {
window.Verde.toast('Please enter a location to search.', 'error');
return;
}
const btn = document.getElementById('geofence-search-btn');
btn.disabled = true;
btn.textContent = 'Fetching…';
const res = await window.Verde.apiFetch(`/api/v1/geo/fetch-boundary?q=${encodeURIComponent(query)}`);
btn.disabled = false;
btn.textContent = 'Fetch Geofence';
if (res.ok) {
const list = res.body.data ?? [];
if (list.length === 0) {
window.Verde.toast('No geofence found for this location.', 'error');
return;
}
const item = list[0];
if (drawnGeofenceLayer) {
leafletMap.removeLayer(drawnGeofenceLayer);
drawnGeofenceLayer = null;
}
const L = window.L;
drawnGeofenceLayer = L.geoJSON(item.geojson, {
color: '#16a34a',
weight: 2.5,
fillColor: '#22c55e',
fillOpacity: 0.3
}).addTo(leafletMap);
leafletMap.fitBounds(drawnGeofenceLayer.getBounds());
drawnGeofenceLayer.eachLayer(layer => {
layer.pm.enable({
allowSelfIntersection: false
});
});
window.Verde.toast(`Fetched geofence: ${item.display_name}`, 'success');
} else {
window.Verde.toast('Failed to fetch geofence.', 'error');
}
});
// Household registration in Service Area logic
const hhModal = document.getElementById('household-modal');
const hhForm = document.getElementById('household-form');
let householdPickerMap = null;
let householdPickerMarker = null;
let householdPickerBoundaryLayer = null;
let householdTenantBoundaryLayer = null;
let currentServiceAreaBarangays = [];
// Modal close listeners
hhModal.querySelectorAll('[data-close]').forEach(el => {
el.addEventListener('click', () => hhModal.classList.add('hidden'));
});
async function onAddHousehold(id) {
hhForm.reset();
document.getElementById('household-lat').value = '';
document.getElementById('household-lng').value = '';
if (householdPickerMarker && householdPickerMap) {
householdPickerMap.removeLayer(householdPickerMarker);
householdPickerMarker = null;
}
if (householdPickerBoundaryLayer && householdPickerMap) {
householdPickerMap.removeLayer(householdPickerBoundaryLayer);
householdPickerBoundaryLayer = null;
}
// Show default panels
hhForm.querySelectorAll('input[name="resident_type"]').forEach(r => {
r.disabled = false;
if (r.value === 'new') r.checked = true;
});
document.getElementById('household-new-resident-fields').classList.remove('hidden');
document.getElementById('household-existing-resident-fields').classList.add('hidden');
const res = await window.Verde.apiFetch(`/api/v1/service-areas/${id}`);
if (!res.ok) {
window.Verde.toast('Failed to load service area details', 'error');
return;
}
const area = res.body.data;
currentServiceAreaBarangays = area.barangays ?? [];
const select = document.getElementById('household-barangay-select');
select.innerHTML = '<option value="">Select a Barangay</option>' +
currentServiceAreaBarangays.map(b => `<option value="${b.id}">${window.Verde.escapeHtml(b.name)}</option>`).join('');
hhModal.classList.remove('hidden');
await initHouseholdPickerMap();
setTimeout(async () => {
householdPickerMap.invalidateSize();
const L = window.L;
const meRes = await window.Verde.apiFetch('/api/v1/me');
if (meRes.ok && meRes.body.user?.tenant?.boundary_polygon) {
await drawHouseholdLguBoundary(L, meRes.body.user.tenant.boundary_polygon);
}
}, 100);
}
async function initHouseholdPickerMap() {
const L = await awaitLeaflet();
if (householdPickerMap) return;
L.Marker.prototype.options.icon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
householdPickerMap = L.map('household-map-picker').setView([12.8797, 121.7740], 6);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap'
}).addTo(householdPickerMap);
householdPickerMap.on('click', (e) => {
const { lat, lng } = e.latlng;
setHouseholdPickerLocation(L, lat, lng);
});
}
function setHouseholdPickerLocation(L, lat, lng) {
document.getElementById('household-lat').value = lat;
document.getElementById('household-lng').value = lng;
if (householdPickerMarker) {
householdPickerMarker.setLatLng([lat, lng]);
} else {
householdPickerMarker = L.marker([lat, lng]).addTo(householdPickerMap);
}
}
async function drawHouseholdLguBoundary(L, boundary) {
if (householdTenantBoundaryLayer) {
householdPickerMap.removeLayer(householdTenantBoundaryLayer);
householdTenantBoundaryLayer = null;
}
if (!boundary || boundary.length === 0) return;
const points = boundary.map(p => [p.lat, p.lng]);
householdTenantBoundaryLayer = L.polygon(points, {
color: '#94a3b8',
weight: 2,
dashArray: '5, 5',
fillColor: '#cbd5e1',
fillOpacity: 0.05,
interactive: false
}).addTo(householdPickerMap);
householdPickerMap.fitBounds(householdTenantBoundaryLayer.getBounds());
}
// Toggle fields for resident type
hhForm.querySelectorAll('input[name="resident_type"]').forEach(radio => {
radio.addEventListener('change', (e) => {
if (e.target.value === 'new') {
document.getElementById('household-new-resident-fields').classList.remove('hidden');
document.getElementById('household-existing-resident-fields').classList.add('hidden');
} else {
document.getElementById('household-new-resident-fields').classList.add('hidden');
document.getElementById('household-existing-resident-fields').classList.remove('hidden');
populateHouseholdResidents();
}
});
});
async function populateHouseholdResidents() {
const select = hhForm.querySelector('select[name="head_user_id"]');
select.innerHTML = '<option value="">Loading residents…</option>';
const res = await window.Verde.apiFetch('/api/v1/admin/users?role=resident&without_household=1');
if (res.ok) {
const list = res.body.data ?? [];
if (list.length === 0) {
select.innerHTML = '<option value="">No residents without households found</option>';
} else {
select.innerHTML = '<option value="">Select Resident</option>' +
list.map(u => `<option value="${u.db_id || u.id}">${window.Verde.escapeHtml(u.full_name)} (${window.Verde.escapeHtml(u.email)})</option>`).join('');
}
} else {
select.innerHTML = '<option value="">Failed to load residents</option>';
}
}
// Barangay Change listener for boundary drawing
document.getElementById('household-barangay-select').addEventListener('change', async (e) => {
const barangayId = e.target.value;
if (!barangayId) {
if (householdPickerBoundaryLayer) {
householdPickerMap.removeLayer(householdPickerBoundaryLayer);
householdPickerBoundaryLayer = null;
}
return;
}
const L = await awaitLeaflet();
const b = currentServiceAreaBarangays.find(x => x.id == barangayId);
if (!b) return;
if (householdPickerBoundaryLayer) {
householdPickerMap.removeLayer(householdPickerBoundaryLayer);
householdPickerBoundaryLayer = null;
}
if (b.boundary && b.boundary.length > 0) {
const points = b.boundary.map(p => [p.lat, p.lng]);
householdPickerBoundaryLayer = L.polygon(points, {
color: '#16a34a',
weight: 2,
fillColor: '#22c55e',
fillOpacity: 0.2,
interactive: false
}).addTo(householdPickerMap);
householdPickerMap.fitBounds(householdPickerBoundaryLayer.getBounds());
if (!document.getElementById('household-lat').value && b.centroid) {
setHouseholdPickerLocation(L, b.centroid.lat, b.centroid.lng);
}
} else if (b.centroid) {
householdPickerMap.setView([b.centroid.lat, b.centroid.lng], 15);
if (!document.getElementById('household-lat').value) {
setHouseholdPickerLocation(L, b.centroid.lat, b.centroid.lng);
}
}
});
// Form submit
hhForm.addEventListener('submit', async (e) => {
e.preventDefault();
const lat = document.getElementById('household-lat').value;
const lng = document.getElementById('household-lng').value;
if (!lat || !lng) {
window.Verde.toast('Please pin the household location on the map.', 'error');
return;
}
const submitFd = new FormData(hhForm);
submitFd.set('lat', lat);
submitFd.set('lng', lng);
const residentType = submitFd.get('resident_type');
if (residentType === 'new') {
submitFd.delete('head_user_id');
} else {
submitFd.delete('first_name');
submitFd.delete('last_name');
submitFd.delete('email');
submitFd.delete('phone');
submitFd.delete('password');
}
const res = await window.Verde.apiFetch('/api/v1/admin/households', {
method: 'POST',
body: submitFd,
});
if (res.ok) {
window.Verde.toast('Household created successfully.', 'success');
hhModal.classList.add('hidden');
load();
} else {
window.Verde.toast(res.body?.message ?? 'Save failed', 'error');

View File

@@ -265,8 +265,8 @@
<!-- Scan Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-neutral-50 p-2 rounded-lg border border-neutral-100">
<p class="text-[10px] font-bold text-neutral-400 uppercase">Trip Scans</p>
<p class="text-lg font-bold text-neutral-900">${stats.current_trip_qr || 0}</p>
<p class="text-[10px] font-bold text-neutral-400 uppercase">Total Scans</p>
<p class="text-lg font-bold text-neutral-900">${stats.total_qr || 0}</p>
</div>
<div class="bg-neutral-50 p-2 rounded-lg border border-neutral-100">
<p class="text-[10px] font-bold text-neutral-400 uppercase">Daily Scans</p>
@@ -311,6 +311,10 @@
<div class="bg-neutral-50 p-3 border-t border-neutral-100 flex gap-2">
<button data-id="${t.id}" data-action="edit" class="flex-1 py-1.5 text-xs font-bold text-neutral-600 hover:bg-white rounded-md border border-transparent hover:border-neutral-200 transition-all">Edit</button>
<a href="/admin/reports?tab=teams&team=${t.uuid ?? t.id}"
class="py-1.5 px-3 text-xs font-bold text-verde-700 hover:bg-verde-50 rounded-md border border-verde-200 hover:border-verde-400 transition-all whitespace-nowrap">
Analytics
</a>
<button data-id="${t.id}" data-action="delete" class="py-1.5 px-3 text-xs font-bold text-red-600 hover:bg-red-50 rounded-md transition-all">Delete</button>
</div>
</div>

View File

@@ -7,7 +7,7 @@
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
<div class="flex items-center gap-4">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-verde-50 text-verde-600">
<i data-lucide="package" class="h-5 w-5"></i>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5 lucide lucide-shelving-unit"><path d="M12 12V9a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3"/><path d="M16 20v-3a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v3"/><path d="M20 22V2"/><path d="M4 12h16"/><path d="M4 20h16"/><path d="M4 2v20"/><path d="M4 4h16"/></svg>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Inventory Balance</p>
@@ -19,7 +19,7 @@
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
<div class="flex items-center gap-4">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600">
<i data-lucide="barcode" class="h-5 w-5"></i>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5 lucide lucide-badge-dollar-sign"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Total Sales</p>
@@ -31,7 +31,7 @@
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
<div class="flex items-center gap-4">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600">
<i data-lucide="trending-up" class="h-5 w-5"></i>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5 lucide lucide-badge-percent"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m15 9-6 6"/><path d="M9 9h.01"/><path d="M15 15h.01"/></svg>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Commissions Earned</p>
@@ -43,7 +43,7 @@
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
<div class="flex items-center gap-4">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-50 text-orange-600">
<i data-lucide="wallet" class="h-5 w-5"></i>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5 lucide lucide-philippine-peso"><path d="M20 11H4"/><path d="M20 7H4"/><path d="M7 21V4a1 1 0 0 1 1-1h4a1 1 0 0 1 0 12H7"/></svg>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Amount Owed</p>

View File

@@ -2,10 +2,12 @@
$nav = [
'Dashboard' => [
['href' => '/store/dashboard', 'label' => 'Dashboard', 'icon' => 'layout-dashboard'],
['href' => '/store/profile', 'label' => 'Store Profile', 'icon' => 'store'],
],
'Business' => [
['href' => '/store/sales', 'label' => 'Record Sale', 'icon' => 'barcode'],
['href' => '/store/inventory', 'label' => 'Inventory', 'icon' => 'package'],
['href' => '/store/qr-purchases', 'label' => 'QR Purchases', 'icon' => 'shopping-cart'],
['href' => '/store/financials', 'label' => 'Financials', 'icon' => 'wallet'],
],
];

View File

@@ -0,0 +1,349 @@
@extends('store.layouts.app', ['pageTitle' => 'Store Profile', 'pageSubtitle' => 'Your business and account details'])
@push('styles')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
#store-map { height: 280px; width: 100%; border-radius: 0.75rem; z-index: 1; }
</style>
@endpush
@section('page')
<div class="space-y-8 max-w-5xl">
{{-- Main Profile Overview Card --}}
<div class="rounded-2xl border border-neutral-200 bg-white p-8 shadow-sm">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-6 border-b border-neutral-100">
<div class="flex items-center gap-4">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-verde-50 text-verde-600">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-8 w-8 lucide lucide-store"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"/><path d="M2 7h20"/><path d="M22 7v3a2 2 0 0 1-2 2v0a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12v0a2 2 0 0 1-2-2V7"/></svg>
</div>
<div>
<h3 id="profile-business-name" class="text-xl font-bold text-neutral-900">Loading store...</h3>
<div class="mt-1 flex items-center gap-2">
<span id="profile-status-badge" class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight bg-neutral-100 text-neutral-600">Pending</span>
<span class="text-xs text-neutral-400">·</span>
<span id="profile-permit" class="text-xs text-neutral-500 font-mono">Permit: -</span>
</div>
</div>
</div>
<div>
<button type="button" id="btn-edit-profile" class="flex items-center gap-2 rounded-xl bg-neutral-900 hover:bg-neutral-800 active:scale-95 px-5 py-2.5 text-xs font-bold text-white shadow-lg shadow-neutral-950/10 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
<span>Edit Profile</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 pt-6">
{{-- Left column: Details --}}
<div class="lg:col-span-2 space-y-8">
{{-- Business Details --}}
<div class="space-y-4">
<h4 class="text-xs font-bold uppercase tracking-widest text-neutral-400">Business Registration</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex justify-between py-2 border-b border-neutral-50 text-sm">
<span class="text-neutral-500">Business Name</span>
<span id="detail-business-name" class="font-semibold text-neutral-900"></span>
</div>
<div class="flex justify-between py-2 border-b border-neutral-50 text-sm">
<span class="text-neutral-500">Business Permit</span>
<span id="detail-permit" class="font-semibold text-neutral-900 font-mono"></span>
</div>
<div class="flex justify-between py-2 border-b border-neutral-50 text-sm">
<span class="text-neutral-500">Commission Rate</span>
<span id="detail-commission" class="font-semibold text-neutral-900"></span>
</div>
<div class="flex justify-between py-2 border-b border-neutral-50 text-sm">
<span class="text-neutral-500">Operating Hours</span>
<span id="detail-hours" class="font-semibold text-neutral-900"></span>
</div>
</div>
</div>
{{-- Location Details --}}
<div class="space-y-4">
<h4 class="text-xs font-bold uppercase tracking-widest text-neutral-400">Address & Location</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex justify-between py-2 border-b border-neutral-50 text-sm">
<span class="text-neutral-500">Address Line</span>
<span id="detail-address" class="font-semibold text-neutral-900 truncate max-w-[200px]" title=""></span>
</div>
<div class="flex justify-between py-2 border-b border-neutral-50 text-sm">
<span class="text-neutral-500">Barangay</span>
<span id="detail-barangay" class="font-semibold text-neutral-900"></span>
</div>
<div class="flex justify-between py-2 border-b border-neutral-50 text-sm">
<span class="text-neutral-500">LGU</span>
<span id="detail-lgu" class="font-semibold text-neutral-900"></span>
</div>
<div class="flex justify-between py-2 border-b border-neutral-50 text-sm">
<span class="text-neutral-500">Coordinates</span>
<span id="detail-coords" class="font-semibold text-neutral-900 font-mono"></span>
</div>
</div>
</div>
</div>
{{-- Right column: Pinned Location Map --}}
<div class="lg:col-span-1 space-y-4">
<h4 class="text-xs font-bold uppercase tracking-widest text-neutral-400">Store Pinned Location</h4>
<div class="rounded-xl border border-neutral-200 overflow-hidden bg-neutral-50 shadow-sm relative">
<div id="store-map"></div>
</div>
</div>
</div>
</div>
{{-- Owner Details Card --}}
<div class="rounded-2xl border border-neutral-200 bg-white p-8 shadow-sm">
<h4 class="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-4">Owner Profile</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="p-4 rounded-xl bg-neutral-50/50 border border-neutral-100 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div>
<p class="text-[10px] font-bold uppercase text-neutral-400 tracking-wider">Full Name</p>
<p id="owner-name" class="text-sm font-semibold text-neutral-900"></p>
</div>
</div>
<div class="p-4 rounded-xl bg-neutral-50/50 border border-neutral-100 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-50 text-orange-600">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
</div>
<div>
<p class="text-[10px] font-bold uppercase text-neutral-400 tracking-wider">Email Address</p>
<p id="owner-email" class="text-sm font-semibold text-neutral-900 truncate max-w-[180px]" title=""></p>
</div>
</div>
<div class="p-4 rounded-xl bg-neutral-50/50 border border-neutral-100 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-phone"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
</div>
<div>
<p class="text-[10px] font-bold uppercase text-neutral-400 tracking-wider">Phone Number</p>
<p id="owner-phone" class="text-sm font-semibold text-neutral-900"></p>
</div>
</div>
</div>
</div>
</div>
{{-- Edit Profile Modal --}}
<div id="edit-profile-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center bg-neutral-900/50 backdrop-blur-sm">
<div class="w-full max-w-lg rounded-2xl bg-white p-8 shadow-2xl ring-1 ring-neutral-200 animate-in fade-in zoom-in-95 duration-200">
<div class="flex items-center justify-between border-b border-neutral-100 pb-4 mb-6">
<h3 class="text-lg font-bold text-neutral-900">Edit Store Profile</h3>
<button type="button" id="btn-close-modal" class="text-neutral-400 hover:text-neutral-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<form id="edit-profile-form" class="space-y-4">
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-neutral-400 mb-1">Business Name</label>
<input type="text" id="edit-business-name" name="business_name" required class="w-full rounded-xl border-neutral-200 focus:ring-verde-500 focus:border-verde-500 py-2.5 px-4 text-sm font-medium">
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-neutral-400 mb-1">Business Permit Number</label>
<input type="text" id="edit-business-permit" name="business_permit_number" class="w-full rounded-xl border-neutral-200 focus:ring-verde-500 focus:border-verde-500 py-2.5 px-4 text-sm font-medium font-mono">
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-neutral-400 mb-1">Address Line</label>
<input type="text" id="edit-address-line" name="address_line" required class="w-full rounded-xl border-neutral-200 focus:ring-verde-500 focus:border-verde-500 py-2.5 px-4 text-sm font-medium">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-neutral-400 mb-1">Open Hours</label>
<input type="text" placeholder="e.g. 08:00 AM" id="edit-hours-open" name="hours_open" class="w-full rounded-xl border-neutral-200 focus:ring-verde-500 focus:border-verde-500 py-2.5 px-4 text-sm font-medium">
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-neutral-400 mb-1">Close Hours</label>
<input type="text" placeholder="e.g. 09:00 PM" id="edit-hours-close" name="hours_close" class="w-full rounded-xl border-neutral-200 focus:ring-verde-500 focus:border-verde-500 py-2.5 px-4 text-sm font-medium">
</div>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-neutral-400 mb-1">Owner Phone Number</label>
<input type="text" id="edit-owner-phone" name="owner_phone" class="w-full rounded-xl border-neutral-200 focus:ring-verde-500 focus:border-verde-500 py-2.5 px-4 text-sm font-medium">
</div>
<div class="pt-4 flex justify-end gap-3">
<button type="button" id="btn-cancel-edit" class="rounded-xl border border-neutral-200 bg-white hover:bg-neutral-50 px-6 py-2.5 text-sm font-bold text-neutral-600 transition-colors">Cancel</button>
<button type="submit" class="rounded-xl bg-verde-600 hover:bg-verde-700 active:scale-95 px-6 py-2.5 text-sm font-bold text-white shadow-lg shadow-verde-600/20 transition-all">Save Changes</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module">
document.addEventListener('DOMContentLoaded', () => {
let storeData = null;
let map = null;
let marker = null;
let boundaryLayer = null;
const modal = document.getElementById('edit-profile-modal');
const openBtn = document.getElementById('btn-edit-profile');
const closeBtn = document.getElementById('btn-close-modal');
const cancelBtn = document.getElementById('btn-cancel-edit');
const form = document.getElementById('edit-profile-form');
// Initialize Map
const initMap = (lat, lng) => {
if (!map) {
map = L.map('store-map', {
zoomControl: false,
attributionControl: false
}).setView([lat, lng], 16);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
maxZoom: 19
}).addTo(map);
L.control.zoom({ position: 'bottomright' }).addTo(map);
} else {
map.setView([lat, lng], 16);
}
// Draw/Update Marker
if (marker) {
marker.setLatLng([lat, lng]);
} else {
marker = L.marker([lat, lng]).addTo(map);
}
};
const openModal = () => {
if (!storeData) return;
document.getElementById('edit-business-name').value = storeData.business_name || '';
document.getElementById('edit-business-permit').value = storeData.business_permit_number || '';
document.getElementById('edit-address-line').value = storeData.address_line || '';
document.getElementById('edit-hours-open').value = storeData.operating_hours?.open || '';
document.getElementById('edit-hours-close').value = storeData.operating_hours?.close || '';
document.getElementById('edit-owner-phone').value = storeData.owner?.phone || '';
modal.classList.remove('hidden');
};
const closeModal = () => {
modal.classList.add('hidden');
};
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
const loadProfile = async () => {
try {
const res = await window.Verde.apiFetch('/api/v1/store/profile');
const data = res.body?.data;
if (!data) return;
storeData = data;
// Overview header
document.getElementById('profile-business-name').textContent = data.business_name;
document.getElementById('profile-permit').textContent = `Permit: ${data.business_permit_number || 'N/A'}`;
const badge = document.getElementById('profile-status-badge');
badge.textContent = data.status;
if (data.status === 'active') {
badge.className = 'rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight bg-emerald-50 text-emerald-700';
} else if (data.status === 'suspended') {
badge.className = 'rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight bg-red-50 text-red-700';
} else {
badge.className = 'rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight bg-orange-50 text-orange-700';
}
// Registration details
document.getElementById('detail-business-name').textContent = data.business_name;
document.getElementById('detail-permit').textContent = data.business_permit_number || 'N/A';
document.getElementById('detail-commission').textContent = `${data.commission_rate_percent}%`;
let hoursText = 'Not Set';
if (data.operating_hours) {
if (typeof data.operating_hours === 'object') {
hoursText = data.operating_hours.open && data.operating_hours.close
? `${data.operating_hours.open} - ${data.operating_hours.close}`
: JSON.stringify(data.operating_hours);
} else {
hoursText = data.operating_hours;
}
}
document.getElementById('detail-hours').textContent = hoursText;
// Location details
const addrEl = document.getElementById('detail-address');
addrEl.textContent = data.address_line || 'N/A';
addrEl.title = data.address_line || '';
document.getElementById('detail-barangay').textContent = data.barangay_name;
document.getElementById('detail-lgu').textContent = data.lgu_name;
document.getElementById('detail-coords').textContent = data.coordinates
? `${data.coordinates.lat.toFixed(5)}, ${data.coordinates.lng.toFixed(5)}`
: 'N/A';
// Owner info
document.getElementById('owner-name').textContent = data.owner?.name || 'N/A';
const emailEl = document.getElementById('owner-email');
emailEl.textContent = data.owner?.email || 'N/A';
emailEl.title = data.owner?.email || '';
document.getElementById('owner-phone').textContent = data.owner?.phone || 'N/A';
// Map rendering
if (data.coordinates) {
initMap(data.coordinates.lat, data.coordinates.lng);
}
} catch (e) {
console.error('Store profile load failed', e);
window.Verde.toast('Failed to load store profile details.', 'error');
}
};
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const payload = {
business_name: formData.get('business_name'),
business_permit_number: formData.get('business_permit_number'),
address_line: formData.get('address_line'),
operating_hours: {
open: formData.get('hours_open'),
close: formData.get('hours_close')
},
owner_phone: formData.get('owner_phone')
};
try {
const res = await window.Verde.apiFetch('/api/v1/store/profile', {
method: 'PUT',
body: payload
});
if (res.ok) {
window.Verde.toast('Store profile updated successfully!', 'success');
closeModal();
await loadProfile();
} else {
const errorMsg = res.body?.message || 'Failed to update store profile.';
window.Verde.toast(errorMsg, 'error');
}
} catch (err) {
console.error('Failed to update store profile', err);
window.Verde.toast('Failed to update store profile.', 'error');
}
});
loadProfile();
});
</script>
@endpush

View File

@@ -0,0 +1,174 @@
@extends('store.layouts.app', ['pageTitle' => 'Pending QR Orders', 'pageSubtitle' => 'Fulfill resident QR code reservations'])
@section('page')
<div class="mx-auto max-w-5xl space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-neutral-900">Queue</h2>
<button onclick="loadPendingOrders()" class="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50">
<i data-lucide="refresh-cw" class="h-4 w-4"></i>
Refresh
</button>
</div>
<div id="loading" class="py-12 text-center text-neutral-500">
<i data-lucide="loader-2" class="mx-auto mb-2 h-8 w-8 animate-spin"></i>
<p class="text-sm">Loading pending orders...</p>
</div>
<div id="empty-state" class="hidden rounded-2xl border border-dashed border-neutral-300 bg-white p-12 text-center">
<i data-lucide="inbox" class="mx-auto mb-3 h-10 w-10 text-neutral-400"></i>
<h3 class="text-lg font-bold text-neutral-900">No Pending Orders</h3>
<p class="mt-1 text-sm text-neutral-500">When residents reserve a QR code at your store, it will appear here.</p>
</div>
<div id="orders-list" class="hidden space-y-4">
<!-- populated by js -->
</div>
</div>
<!-- Fulfillment Modal -->
<div id="fulfill-modal" class="fixed inset-0 z-50 hidden bg-neutral-900/50 backdrop-blur-sm">
<div class="flex min-h-screen items-center justify-center p-4">
<div class="w-full max-w-md rounded-2xl bg-white shadow-2xl">
<div class="border-b border-neutral-200 p-6">
<h3 class="text-lg font-bold text-neutral-900">Fulfill QR Order</h3>
<p class="mt-1 text-sm text-neutral-500">Confirm payment and link the physical sticker.</p>
</div>
<div class="p-6 space-y-6">
<div class="rounded-xl bg-amber-50 p-4 ring-1 ring-amber-200">
<p class="text-xs font-bold text-amber-900 uppercase tracking-wide">Collect Payment</p>
<p class="mt-1 text-2xl font-black text-amber-950" id="modal-amount">₱50.00</p>
<p class="mt-1 text-xs text-amber-700">Resident: <span id="modal-resident" class="font-semibold"></span></p>
<p class="text-xs text-amber-700">Reservation: <span id="modal-code" class="font-mono font-semibold"></span></p>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">Scan or Enter QR Serial</label>
<input type="text" id="qr-serial" placeholder="VERDE-XXXX-XXXX" class="w-full rounded-xl border-neutral-200 py-3 font-mono text-sm uppercase tracking-wider focus:ring-verde-500 focus:border-verde-500" autocomplete="off">
<p class="mt-2 text-[10px] text-neutral-500">Scan the physical QR sticker or type its serial number.</p>
</div>
</div>
<div class="flex items-center justify-end gap-3 rounded-b-2xl border-t border-neutral-100 bg-neutral-50 p-4">
<button type="button" onclick="closeModal()" class="rounded-xl px-4 py-2 text-sm font-bold text-neutral-600 hover:bg-neutral-200 transition-colors">Cancel</button>
<button type="button" id="btn-complete" class="rounded-xl bg-verde-600 px-6 py-2 text-sm font-bold text-white shadow-md hover:bg-verde-700 transition-colors">
Complete & Link
</button>
</div>
</div>
</div>
</div>
<script type="module">
let currentOrderUuid = null;
window.loadPendingOrders = async function() {
document.getElementById('loading').classList.remove('hidden');
document.getElementById('empty-state').classList.add('hidden');
document.getElementById('orders-list').classList.add('hidden');
try {
const res = await window.Verde.apiFetch('/api/v1/store/qr-purchases/pending');
const orders = res.body?.data || [];
if (orders.length === 0) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('empty-state').classList.remove('hidden');
return;
}
const list = document.getElementById('orders-list');
list.innerHTML = '';
orders.forEach(order => {
const residentName = order.resident ? (order.resident.first_name + ' ' + order.resident.last_name) : 'Unknown Resident';
const el = document.createElement('div');
el.className = 'flex items-center justify-between rounded-xl border border-neutral-200 bg-white p-5 shadow-sm transition-all hover:shadow-md';
el.innerHTML = `
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-verde-100 text-verde-700">
<i data-lucide="ticket" class="h-6 w-6"></i>
</div>
<div>
<div class="flex items-center gap-2">
<h4 class="font-bold text-neutral-900">${residentName}</h4>
<span class="rounded bg-neutral-100 px-2 py-0.5 font-mono text-[10px] font-bold tracking-wider text-neutral-600">${order.reservation_code}</span>
</div>
<p class="text-xs text-neutral-500">Ordered: ${new Date(order.created_at).toLocaleString()}</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-right">
<p class="text-[10px] font-bold uppercase tracking-widest text-neutral-400">To Collect</p>
<p class="text-lg font-black text-neutral-900">${order.amount}</p>
</div>
<button onclick="openModal('${order.uuid}', '${residentName}', '${order.reservation_code}', '${order.amount}')" class="rounded-xl bg-neutral-900 px-5 py-2.5 text-sm font-bold text-white shadow hover:bg-black transition-colors">
Fulfill
</button>
</div>
`;
list.appendChild(el);
});
document.getElementById('loading').classList.add('hidden');
list.classList.remove('hidden');
window.lucide.createIcons();
} catch (e) {
console.error('Failed to load orders', e);
document.getElementById('loading').classList.add('hidden');
window.Verde.toast('Failed to load orders', 'error');
}
};
window.openModal = function(uuid, resident, code, amount) {
currentOrderUuid = uuid;
document.getElementById('modal-resident').textContent = resident;
document.getElementById('modal-code').textContent = code;
document.getElementById('modal-amount').textContent = '₱' + amount;
document.getElementById('qr-serial').value = '';
document.getElementById('fulfill-modal').classList.remove('hidden');
setTimeout(() => document.getElementById('qr-serial').focus(), 100);
};
window.closeModal = function() {
currentOrderUuid = null;
document.getElementById('fulfill-modal').classList.add('hidden');
};
document.getElementById('btn-complete').addEventListener('click', async () => {
const serial = document.getElementById('qr-serial').value.trim();
if (!serial) {
window.Verde.toast('Please scan or enter a QR serial.', 'error');
return;
}
const btn = document.getElementById('btn-complete');
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="h-4 w-4 animate-spin"></i> Processing...';
try {
const res = await window.Verde.apiFetch('/api/v1/store/qr-purchases/' + currentOrderUuid + '/complete', {
method: 'POST',
body: JSON.stringify({ scanned_qr_data: serial })
});
if (res.ok) {
window.Verde.toast('Order completed and QR linked successfully!', 'success');
closeModal();
loadPendingOrders();
} else {
window.Verde.toast(res.body?.message || 'Failed to complete order.', 'error');
}
} catch (e) {
console.error(e);
window.Verde.toast('An error occurred.', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Complete & Link';
}
});
document.addEventListener('DOMContentLoaded', () => {
loadPendingOrders();
});
</script>
@endsection

View File

@@ -57,14 +57,15 @@
<div>
<label class="block text-xs font-bold uppercase tracking-widest text-neutral-400 mb-2">Quantity</label>
<div class="flex items-center gap-2">
<button type="button" onclick="adjustQty(-1)" class="flex h-12 w-12 items-center justify-center rounded-xl border border-neutral-200 bg-white hover:bg-neutral-50 active:scale-95 transition-all">
<i data-lucide="minus" class="h-4 w-4"></i>
<button type="button" id="btn-minus" onclick="adjustQty(-1)" class="flex h-12 w-12 items-center justify-center rounded-xl border border-neutral-200 bg-white hover:bg-neutral-50 active:scale-95 transition-all text-neutral-600 font-bold text-xl">
-
</button>
<input type="number" id="sale-qty" value="1" min="1" class="h-12 w-20 rounded-xl border-neutral-200 text-center font-bold text-lg focus:ring-verde-500 focus:border-verde-500">
<button type="button" onclick="adjustQty(1)" class="flex h-12 w-12 items-center justify-center rounded-xl border border-neutral-200 bg-white hover:bg-neutral-50 active:scale-95 transition-all">
<i data-lucide="plus" class="h-4 w-4"></i>
<button type="button" id="btn-plus" onclick="adjustQty(1)" class="flex h-12 w-12 items-center justify-center rounded-xl border border-neutral-200 bg-white hover:bg-neutral-50 active:scale-95 transition-all text-neutral-600 font-bold text-xl">
+
</button>
</div>
<p class="mt-1 text-xs text-neutral-400">Available stock: <span id="stock-badge" class="font-bold text-neutral-600"></span></p>
</div>
<div class="flex flex-col justify-end items-end">
<p class="text-[10px] font-black uppercase tracking-tighter text-neutral-400">Total Amount</p>
@@ -143,11 +144,18 @@
};
// Fetch Settings
let stockBalance = Infinity;
const fetchSettings = async () => {
const res = await window.Verde.apiFetch('/api/v1/store/settings');
const data = res.body?.data;
if (data) {
retailPrice = data.retail_price_centavos;
stockBalance = data.inventory_balance ?? Infinity;
const input = document.getElementById('sale-qty');
input.max = stockBalance;
document.getElementById('stock-badge').textContent = stockBalance;
updateButtons();
updateTotal();
}
};
@@ -160,13 +168,41 @@
}).format(total);
};
window.adjustQty = (delta) => {
const input = document.getElementById('sale-qty');
input.value = Math.max(1, parseInt(input.value) + delta);
updateTotal();
const updateButtons = () => {
const qty = parseInt(document.getElementById('sale-qty').value) || 1;
const btnPlus = document.getElementById('btn-plus');
const btnMinus = document.getElementById('btn-minus');
const atMax = qty >= stockBalance;
const atMin = qty <= 1;
btnPlus.disabled = atMax;
btnPlus.classList.toggle('opacity-30', atMax);
btnPlus.classList.toggle('cursor-not-allowed', atMax);
btnMinus.disabled = atMin;
btnMinus.classList.toggle('opacity-30', atMin);
btnMinus.classList.toggle('cursor-not-allowed', atMin);
// Update stock badge color
const badge = document.getElementById('stock-badge');
badge.classList.toggle('text-red-500', atMax);
badge.classList.toggle('text-neutral-600', !atMax);
};
document.getElementById('sale-qty').addEventListener('input', updateTotal);
window.adjustQty = (delta) => {
const input = document.getElementById('sale-qty');
const newVal = Math.min(stockBalance, Math.max(1, parseInt(input.value) + delta));
input.value = newVal;
updateTotal();
updateButtons();
};
document.getElementById('sale-qty').addEventListener('input', () => {
const input = document.getElementById('sale-qty');
if (stockBalance !== Infinity && parseInt(input.value) > stockBalance) {
input.value = stockBalance;
}
if (parseInt(input.value) < 1 || !input.value) input.value = 1;
updateTotal();
updateButtons();
});
let searchMode = 'household';
let tomSelectInstance = null;

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\Admin\AdminPartnerStoreController;
use App\Http\Controllers\Api\V1\Admin\AdminQrBatchController;
use App\Http\Controllers\Api\V1\Admin\AdminQrCodeController;
use App\Http\Controllers\Api\V1\Admin\AdminReportController;
use App\Http\Controllers\Api\V1\Admin\AdminTeamReportController;
use App\Http\Controllers\Api\V1\Admin\AdminRouteController;
use App\Http\Controllers\Api\V1\Admin\AdminTeamController;
use App\Http\Controllers\Api\V1\Admin\AdminTripController;
@@ -33,6 +34,7 @@ use App\Http\Controllers\Api\V1\Driver\DriverTripController;
use App\Http\Controllers\Api\V1\Driver\TripDetourController;
use App\Http\Controllers\Api\V1\DropOff\DropOffPointController;
use App\Http\Controllers\Api\V1\Geo\BarangayController;
use App\Http\Controllers\Api\V1\Geo\FetchBoundaryController;
use App\Http\Controllers\Api\V1\Geo\CityMunicipalityController;
use App\Http\Controllers\Api\V1\Geo\ProvinceController;
use App\Http\Controllers\Api\V1\Geo\RegionController;
@@ -107,6 +109,10 @@ Route::middleware('auth:sanctum')->prefix('me')->name('api.v1.me.')->group(funct
Route::get('/household', [MyHouseholdController::class, 'show'])->name('household.show');
// QR Purchases (Resident)
Route::get('/qr-purchases', [\App\Http\Controllers\Api\V1\Resident\QrPurchaseController::class, 'index'])->name('qr-purchases.index');
Route::post('/qr-purchases', [\App\Http\Controllers\Api\V1\Resident\QrPurchaseController::class, 'store'])->name('qr-purchases.store');
Route::get('/collections', [MyCollectionsController::class, 'index'])->name('collections');
Route::get('/upcoming-pickups', [UpcomingPickupsController::class, 'index'])->name('upcoming-pickups');
@@ -147,6 +153,10 @@ Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api.
// Store Portal
Route::middleware(['auth:sanctum', 'role:store_partner'])->prefix('store')->name('api.v1.store.')->group(function () {
Route::get('/dashboard', [StorePortalController::class, 'dashboard'])->name('dashboard');
// QR Purchases (Fulfillment)
Route::get('/qr-purchases/pending', [\App\Http\Controllers\Api\V1\Store\StoreQrPurchaseController::class, 'pending'])->name('qr-purchases.pending');
Route::post('/qr-purchases/{uuid}/complete', [\App\Http\Controllers\Api\V1\Store\StoreQrPurchaseController::class, 'complete'])->name('qr-purchases.complete');
Route::get('/analytics', [StorePortalController::class, 'analytics'])->name('analytics');
Route::post('/sales', [StorePortalController::class, 'recordSale'])->name('sales.store');
Route::get('/sales', [StorePortalController::class, 'salesHistory'])->name('sales.index');
@@ -154,6 +164,8 @@ Route::middleware(['auth:sanctum', 'role:store_partner'])->prefix('store')->name
Route::get('/households', [StorePortalController::class, 'searchHouseholds'])->name('households.search');
Route::get('/users', [StorePortalController::class, 'searchResidents'])->name('users.search');
Route::get('/settings', [StorePortalController::class, 'getSettings'])->name('settings');
Route::get('/profile', [StorePortalController::class, 'profile'])->name('profile');
Route::put('/profile', [StorePortalController::class, 'updateProfile'])->name('profile.update');
});
// Store Portal signed routes
@@ -326,6 +338,7 @@ Route::prefix('admin/partner-stores')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminPartnerStoreController::class, 'index'])->name('index');
Route::get('/overall-chart', [AdminPartnerStoreController::class, 'overallDistributionChart'])->name('overall-chart');
Route::post('/', [AdminPartnerStoreController::class, 'store'])->name('store');
Route::patch('/{store}', [AdminPartnerStoreController::class, 'update'])->name('update');
Route::post('/{store}/issue-inventory', [AdminPartnerStoreController::class, 'issueInventory'])->name('issue-inventory');
@@ -334,6 +347,7 @@ Route::prefix('admin/partner-stores')
Route::get('/{store}/sales-history', [AdminPartnerStoreController::class, 'salesHistory'])->name('sales-history');
Route::get('/{store}/inventory-history', [AdminPartnerStoreController::class, 'inventoryLog'])->name('inventory-history');
Route::get('/{store}/analytics', [AdminPartnerStoreController::class, 'analytics'])->name('analytics');
Route::get('/{store}/distribution-chart', [AdminPartnerStoreController::class, 'distributionChart'])->name('distribution-chart');
Route::post('/{store}/report-issue', [AdminPartnerStoreController::class, 'reportIssue'])->name('report-issue');
Route::post('/{store}/adjust-stock', [AdminPartnerStoreController::class, 'adjustInventory'])->name('adjust-stock');
Route::post('/{store}/settle', [AdminPartnerStoreController::class, 'recordPayment'])->name('settle');
@@ -351,6 +365,9 @@ Route::prefix('admin/reports')
Route::get('/store-sales', [AdminReportController::class, 'storeSales'])->name('store-sales');
Route::get('/compliance.csv', [AdminReportController::class, 'complianceCsv'])->name('compliance-csv');
Route::post('/rebuild', [AdminReportController::class, 'rebuild'])->name('rebuild');
// Team analytics — leaderboard MUST be before {team:uuid} to avoid route conflict
Route::get('/teams/leaderboard', [AdminTeamReportController::class, 'leaderboard'])->name('teams.leaderboard');
Route::get('/teams/{team:uuid}/profile', [AdminTeamReportController::class, 'teamProfile'])->name('teams.profile');
});
Route::prefix('admin/trips')
@@ -418,6 +435,7 @@ Route::prefix('geo')->name('api.v1.geo.')->group(function () {
Route::get('/cities', CityMunicipalityController::class)->name('cities.index');
Route::get('/barangays', BarangayController::class)->name('barangays.index');
Route::post('/resolve', ResolveLocationController::class)->name('resolve');
Route::get('/fetch-boundary', FetchBoundaryController::class)->name('fetch-boundary')->middleware('auth:sanctum');
});
Route::prefix('service-areas')

View File

@@ -53,5 +53,7 @@ Route::prefix('store')->name('store.')->group(function () {
Route::get('/dashboard', [StoreDashboardController::class, 'index'])->name('dashboard');
Route::get('/sales', [StoreDashboardController::class, 'sales'])->name('sales');
Route::get('/inventory', [StoreDashboardController::class, 'inventory'])->name('inventory');
Route::get('/qr-purchases', [StoreDashboardController::class, 'qrPurchases'])->name('qr-purchases');
Route::get('/financials', [StoreDashboardController::class, 'financials'])->name('financials');
Route::get('/profile', [StoreDashboardController::class, 'profile'])->name('profile');
});

428
team-reports.md Normal file
View File

@@ -0,0 +1,428 @@
# Team Reports Analytics — Implementation Plan
> **Feature:** Add a "Teams" tab to `/admin/reports` with a Leaderboard + Team Profile drill-down, charts, incident logs, scan trends, and LGU filtering (Option D).
> **Project Type:** WEB
> **Plan File:** `team-reports.md`
> **Created:** 2026-07-03
---
## Overview
Verde currently has four report tabs: Daily Collection, Trip Performance, Store Sales, and Compliance Export.
This plan adds a fifth tab — **Teams** — that gives admins a ranked overview of all collection teams and lets them drill into any team's full analytics history.
**Architecture:**
```
Teams Tab (date-filtered, LGU-aware)
└── Leaderboard Table (all teams ranked by KPIs)
└── [View Report] drawer/slide-in panel
├── KPI Hero Cards
├── Daily Scan Line/Bar Chart (last 30 days)
├── Weekly Scan Bar Chart (last 12 weeks)
├── Trip History Table (paginated)
└── Incident / Event Log (ALL 14 event types)
```
**No new migrations needed** — all data lives in `trips`, `collection_logs`, and `trip_timeline_events`.
---
## Success Criteria
- [ ] "Teams" tab appears in the Reports page tab bar
- [ ] Leaderboard loads all teams for the current LGU (or all LGUs for super-admin)
- [ ] Super-admin can filter by specific LGU OR view all LGUs combined
- [ ] Each team row shows: Rank, Name, Driver, Total Trips, Completion %, Total Scans, Avg Load, Incidents
- [ ] Clicking "View Report" opens a slide-in drawer
- [ ] Drawer shows KPI cards, two Chart.js charts, trip history table, full event log
- [ ] Event log includes ALL TripTimelineEvent types (all 14)
- [ ] All data is date-range filtered (default: last 30 days)
- [ ] API accepts `tenant_id` param (super-admin) or uses current tenant context
---
## Tech Stack
| Layer | Technology | Rationale |
|-------|-----------|-----------|
| Backend | Laravel PHP | Matches existing codebase |
| API | New `AdminTeamReportController` | Consistent with existing pattern |
| Frontend | Blade + vanilla JS | Consistent with existing reports page |
| Charts | Chart.js 4.4.0 (CDN) | Lightweight, no build step needed |
| Data Sources | `trips`, `collection_logs`, `trip_timeline_events` | Already exists, no new migrations |
---
## File Structure
```
app/Http/Controllers/Api/V1/Admin/
AdminTeamReportController.php [NEW]
routes/
api.php [MODIFY — add 2 routes]
resources/views/admin/
reports.blade.php [MODIFY — add Teams tab + panel + chart JS]
```
**Total: 1 new file, 2 modified files.**
---
## Task Breakdown
---
### PHASE 1 — Backend API
#### Task 1.1 — `leaderboard` endpoint
**Agent:** `backend-specialist` | **Skill:** `api-patterns` | **Priority:** P0
**File:** `[NEW] app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php`
**Endpoint:** `GET /api/v1/admin/reports/teams/leaderboard`
**Query params:**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `from` | date | -30 days | Range start |
| `to` | date | today | Range end |
| `tenant_id` | int | null | Super-admin: specific LGU, omit for all |
| `per_page` | int | 50 | Pagination |
**Response shape (per team row):**
```json
{
"team_uuid": "...",
"team_name": "Team Alpha",
"driver_name": "Juan Dela Cruz",
"status": "active",
"total_trips": 24,
"completed_trips": 22,
"cancelled_trips": 1,
"completion_rate_percent": 91.7,
"total_scans": 1204,
"total_weight_kg": 4320,
"avg_load_per_trip_kg": 180,
"incident_count": 2,
"last_trip_date": "2026-07-02"
}
```
**Logic (raw SQL aggregates per team):**
- `total_trips` = `trips.count() whereBetween scheduled_date`
- `completed_trips` = status IN `[completed, handed_off]`
- `total_scans` = `collection_logs.count() whereBetween scanned_at`
- `total_weight_kg` = `trips.sum(total_load_kg)`
- `incident_count` = `trip_timeline_events.count()` for all event types in team's trips
- Sorted by `total_scans DESC`
For super-admin with `tenant_id = null`: query ALL tenants (bypass `HasTenant` scope).
**INPUT:** Date range + optional tenant_id
**OUTPUT:** Paginated leaderboard array
**VERIFY:** `GET /api/v1/admin/reports/teams/leaderboard?from=2026-06-01&to=2026-07-03` returns 200 with team rows
---
#### Task 1.2 — `teamProfile` endpoint
**Agent:** `backend-specialist` | **Skill:** `api-patterns` | **Priority:** P0
**Endpoint:** `GET /api/v1/admin/reports/teams/{team:uuid}/profile`
**Query params:** `from`, `to`, `tenant_id`
**Response shape:**
```json
{
"team": {
"uuid": "...", "name": "Team Alpha",
"driver": { "name": "Juan" },
"scanner": { "name": "Maria" },
"truck": { "plate": "ABC-123" },
"status": "active"
},
"kpis": {
"total_trips": 24,
"completed_trips": 22,
"cancelled_trips": 1,
"completion_rate_percent": 91.7,
"total_scans": 1204,
"total_weight_kg": 4320,
"avg_load_per_trip_kg": 180,
"on_time_trips": 20,
"on_time_rate_percent": 83.3,
"incident_count": 5,
"stops_skipped_count": 3,
"detours_count": 2,
"breakdowns_count": 1
},
"daily_scans": [
{ "date": "2026-06-01", "scans": 48 }
],
"weekly_scans": [
{ "week_start": "2026-06-01", "scans": 312 }
],
"trips": {
"data": [
{
"trip_number": "TRIP-20260601-001",
"scheduled_date": "2026-06-01",
"status": "completed",
"scans_count": 52,
"total_load_kg": 185,
"duration_minutes": 143
}
],
"meta": { "page": 1, "per_page": 10, "total": 24, "last_page": 3 }
},
"events": [
{
"event_type": "incident_reported",
"event_at": "2026-06-15T10:30:00",
"trip_number": "TRIP-20260615-001",
"notes": "Truck breakdown near Barangay 5",
"metadata": {}
}
]
}
```
**On-time logic:** Trip is on-time if `actual_start_time <= scheduled_start_time + 30 min`
**Events:** Include ALL 14 `TripTimelineEvent` types:
`trip_started`, `arrived_at_stop`, `collection_started`, `qr_scanned`, `collection_completed`,
`departed_stop`, `stop_skipped`, `truck_full_warning`, `arrived_at_dumpsite`, `load_released`,
`departed_dumpsite`, `trip_completed`, `incident_reported`, `breakdown`,
`detour_to_dumpsite`, `resumed_from_detour`, `continuation_created`
Events: limit to last 200 per range, ordered by `event_at DESC`.
**daily_scans:** GROUP BY DATE(scanned_at) from collection_logs for this team's trips.
**weekly_scans:** GROUP BY YEARWEEK(scanned_at) for last 12 weeks.
**INPUT:** Team UUID + date range
**OUTPUT:** Full analytics profile
**VERIFY:** Profile endpoint returns all 6 top-level keys: `team`, `kpis`, `daily_scans`, `weekly_scans`, `trips`, `events`
---
#### Task 1.3 — Register routes
**Agent:** `backend-specialist` | **Priority:** P0 (depends on T1.1, T1.2)
**File:** `[MODIFY] routes/api.php`
Add inside the existing admin middleware group (near existing reports routes):
```php
Route::get('reports/teams/leaderboard', [AdminTeamReportController::class, 'leaderboard']);
Route::get('reports/teams/{team:uuid}/profile', [AdminTeamReportController::class, 'teamProfile']);
```
**INPUT:** T1.1 + T1.2 controllers complete
**OUTPUT:** Routes registered
**VERIFY:** `php artisan route:list | findstr team` shows both routes
---
### PHASE 2 — Frontend UI
#### Task 2.1 — Add "Teams" tab + section skeleton
**Agent:** `frontend-specialist` | **Skill:** `frontend-design` | **Priority:** P1
**File:** `[MODIFY] resources/views/admin/reports.blade.php`
Changes:
1. Add `['key' => 'teams', 'label' => 'Teams']` to the tab foreach array
2. Add empty `<section data-panel="teams" class="report-panel hidden">` after the compliance section
**INPUT:** Existing tab nav foreach
**OUTPUT:** "Teams" tab button appears in nav
**VERIFY:** Clicking "Teams" hides other panels, shows teams panel (even if empty)
---
#### Task 2.2 — Leaderboard panel (filter bar + ranked table)
**Agent:** `frontend-specialist` | **Skill:** `frontend-design` | **Priority:** P1 (depends on T2.1)
**UI inside `data-panel="teams"` section:**
Filter bar:
- From/To date inputs (ids: `teams-from`, `teams-to`)
- Super-admin LGU selector with "All LGUs" option (id: `teams-lgu`, populated from `lguData`)
- Load button (id: `teams-load`)
Summary KPI cards row (4 cards):
- Total Teams, Total Trips, Total Scans, Total Incidents
Leaderboard table columns:
`Rank | Team | Driver | Status | Trips | Completion % | Total Scans | Avg Load (kg) | Incidents | [View Report]`
- Rank = `(page - 1) * perPage + index + 1`
- "View Report" button has `data-team-uuid` and `data-team-name` attributes
- Status badge: active=green, inactive=gray, standby=amber
**JS function:** `async function loadTeams(page = 1)`
**INPUT:** Leaderboard API (T1.1)
**OUTPUT:** Ranked paginated table
**VERIFY:** Table loads, ranks increment, View Report button exists per row
---
#### Task 2.3 — Team Profile slide-in drawer
**Agent:** `frontend-specialist` | **Skill:** `frontend-design` | **Priority:** P1 (depends on T2.2)
**Drawer structure** (fixed right panel, `max-w-3xl`, slides in via `translate-x-full``translate-x-0`):
Sections inside scrollable drawer body:
1. **Header:** Team name, driver/scanner/truck info, date range badge, Close button
2. **KPI cards grid (2×4):** Total Trips, Completed, On-Time %, Avg Load, Total Scans, Total Weight, Incidents, Detours
3. **Charts row (2 side by side):**
- Daily Scans — bar chart (last 30 days, x=dates)
- Weekly Scans — bar chart (last 12 weeks, x=week start dates)
4. **Trip History table:** Trip #, Date, Status, Scans, Load, Duration (paginated, 10/page)
5. **Event Log table:** Time, Trip #, Event (badge), Notes
Chart.js CDN added to blade head:
```html
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
```
Verde green color scheme for charts: `rgba(45,131,65,0.75)` fill, `#2d8341` border.
**JS functions:**
- `openTeamDrawer(uuid, name)` — fetches profile, destroys old charts, renders all sections
- `closeTeamDrawer()` — hides drawer + backdrop, destroys charts
- `renderDailyChart(data)` / `renderWeeklyChart(data)` — Chart.js bar charts
- `renderTripsPage(page)` — paginated trip table within drawer
- Backdrop click closes drawer
**INPUT:** Team profile API (T1.2)
**OUTPUT:** Slide-in drawer with all 5 sections
**VERIFY:** Open drawer → charts render → close → open different team → charts refresh without error
---
#### Task 2.4 — Event type labels + color badges
**Agent:** `frontend-specialist` | **Priority:** P2 (polish, depends on T2.3)
```js
const EVENT_LABELS = {
trip_started: { label: 'Trip Started', css: 'bg-green-50 text-green-700' },
arrived_at_stop: { label: 'Arrived at Stop', css: 'bg-blue-50 text-blue-700' },
collection_started: { label: 'Collection Start', css: 'bg-blue-50 text-blue-600' },
qr_scanned: { label: 'QR Scanned', css: 'bg-neutral-100 text-neutral-600' },
collection_completed: { label: 'Collection Done', css: 'bg-blue-50 text-blue-700' },
departed_stop: { label: 'Departed Stop', css: 'bg-neutral-50 text-neutral-500' },
stop_skipped: { label: 'Stop Skipped', css: 'bg-amber-50 text-amber-700' },
truck_full_warning: { label: 'Truck Full', css: 'bg-orange-50 text-orange-700' },
arrived_at_dumpsite: { label: 'At Dumpsite', css: 'bg-teal-50 text-teal-700' },
load_released: { label: 'Load Released', css: 'bg-teal-50 text-teal-600' },
departed_dumpsite: { label: 'Left Dumpsite', css: 'bg-neutral-50 text-neutral-500' },
trip_completed: { label: '✓ Trip Completed', css: 'bg-green-100 text-green-800' },
incident_reported: { label: '⚠ Incident', css: 'bg-red-50 text-red-700' },
breakdown: { label: '🔧 Breakdown', css: 'bg-red-100 text-red-800' },
detour_to_dumpsite: { label: 'Detour', css: 'bg-amber-50 text-amber-700' },
resumed_from_detour: { label: 'Resumed', css: 'bg-green-50 text-green-600' },
continuation_created: { label: 'Continuation', css: 'bg-neutral-50 text-neutral-600' },
};
```
**INPUT:** Raw event type strings from API
**OUTPUT:** Colored badge pill in event log table
**VERIFY:** All 17 event types render a distinct readable badge
---
### PHASE 3 — Integration & Polish
#### Task 3.1 — Wire LGU selector to teams tab
**Agent:** `frontend-specialist` | **Priority:** P2
Extend existing `loadAll()`:
```js
else if (activeTab === 'teams') loadTeams(1);
```
Teams-specific LGU selector (`teams-lgu`) is independent of main `lgu-selector`.
It offers "All LGUs" (blank `tenant_id`) so super-admins can see cross-tenant leaderboard.
Populated from `lguData` same as main selector.
**VERIFY:** Switch teams-lgu → leaderboard reloads; "All LGUs" → combined cross-tenant results
---
#### Task 3.2 — Chart destroy/recreate guard
**Agent:** `frontend-specialist` | **Priority:** P2
```js
let dailyChart = null;
let weeklyChart = null;
function destroyCharts() {
if (dailyChart) { dailyChart.destroy(); dailyChart = null; }
if (weeklyChart) { weeklyChart.destroy(); weeklyChart = null; }
}
// Called at top of openTeamDrawer()
```
**VERIFY:** Open drawer for Team A, close, open Team B → no "Canvas already in use" console error
---
## API Contract Summary
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api/v1/admin/reports/teams/leaderboard` | GET | admin, super_admin | Ranked teams list |
| `/api/v1/admin/reports/teams/{uuid}/profile` | GET | admin, super_admin | Full team profile |
Both endpoints:
- Use `Tenancy::current()` for regular admins (scoped to their LGU)
- Accept `?tenant_id=` for super-admin to switch LGU
- When super-admin sends no `tenant_id` → query all tenants (unscoped)
---
## Implementation Order
```
T1.1 ──┐
T1.2 ──┴──► T1.3 ──► T2.1 ──► T2.2 ──► T2.3 ──► T2.4 ──► T3.1 + T3.2
```
**Estimated effort:** ~35 hours (2h backend, 2h frontend, 1h polish/testing)
---
## Risk Register
| Risk | Likelihood | Mitigation |
|------|-----------|-----------|
| Slow profile query on teams with many logs | Medium | Limit events to last 200; paginate trips (10/page) |
| Chart.js canvas reuse error | Low | Destroy chart instances before recreating (T3.2) |
| Cross-tenant query for "All LGUs" | Medium | Use `withoutGlobalScopes()` or explicit unscoped query |
| Route ordering conflict (leaderboard vs {team:uuid}) | Low | Register `leaderboard` route BEFORE `{team:uuid}` route |
---
## Phase X: Verification Checklist
- [ ] `php artisan route:list | findstr team` shows both routes
- [ ] Leaderboard API returns 200 with correct shape
- [ ] Profile API returns all 6 sections: `team`, `kpis`, `daily_scans`, `weekly_scans`, `trips`, `events`
- [ ] "Teams" tab visible and clickable in reports page
- [ ] Leaderboard table renders with rank numbers
- [ ] KPI summary cards above leaderboard update on load
- [ ] "View Report" button opens drawer with smooth slide-in
- [ ] Drawer KPI cards match API kpis values
- [ ] Daily scan bar chart renders with correct date labels
- [ ] Weekly scan bar chart renders with correct week labels
- [ ] Trip history table paginates correctly within drawer
- [ ] Event log shows all event types with color badges
- [ ] Super-admin can switch LGU in teams-lgu selector → data updates
- [ ] "All LGUs" option works → shows combined cross-tenant data
- [ ] Chart.js charts destroy/recreate cleanly on drawer reopen
- [ ] No console errors in browser
- [ ] Drawer is scrollable on small screens

View File

@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature\Api\V1\Geo;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class FetchBoundaryTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->admin = User::factory()->create([
'role' => User::ROLE_ADMIN,
'status' => User::STATUS_ACTIVE,
]);
}
public function test_authenticated_admin_can_fetch_boundary_via_nominatim_proxy(): void
{
Sanctum::actingAs($this->admin);
// Mock Nominatim API response
Http::fake([
'https://nominatim.openstreetmap.org/search*' => Http::response([
[
'display_name' => 'Baesa, Quezon City, Metro Manila, Philippines',
'geojson' => [
'type' => 'Polygon',
'coordinates' => [
[
[121.015, 14.675],
[121.025, 14.675],
[121.025, 14.685],
[121.015, 14.685],
[121.015, 14.675],
]
]
]
]
], 200)
]);
$response = $this->getJson('/api/v1/geo/fetch-boundary?q=Baesa');
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'*' => [
'display_name',
'geojson',
]
]
]);
$this->assertCount(1, $response->json('data'));
$this->assertEquals('Baesa, Quezon City, Metro Manila, Philippines', $response->json('data.0.display_name'));
}
public function test_unauthenticated_user_cannot_fetch_boundary(): void
{
$response = $this->getJson('/api/v1/geo/fetch-boundary?q=Baesa');
$response->assertStatus(401);
}
public function test_fetch_boundary_requires_query_parameter(): void
{
Sanctum::actingAs($this->admin);
$response = $this->getJson('/api/v1/geo/fetch-boundary');
$response->assertStatus(422)
->assertJsonValidationErrors(['q']);
}
}

View File

@@ -200,6 +200,29 @@ class HouseholdLifecycleTest extends TestCase
$this->assertCount(2, $response->json('data'));
}
public function test_admin_can_filter_households_by_service_area(): void
{
Sanctum::actingAs($this->admin);
$area1 = \App\Models\ServiceArea::factory()->create();
$barangay1 = \App\Models\Barangay::first();
$area1->barangays()->attach($barangay1);
$area2 = \App\Models\ServiceArea::factory()->create();
$barangay2 = \App\Models\Barangay::skip(1)->first();
$area2->barangays()->attach($barangay2);
$h1 = Household::factory()->create(['barangay_id' => $barangay1->id]);
$h2 = Household::factory()->create(['barangay_id' => $barangay2->id]);
// Filter by Area 1
$response = $this->getJson("/api/v1/admin/households?service_area_id={$area1->id}");
$response->assertOk();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $h1->uuid);
$response->assertJsonPath('data.0.service_area', $area1->name);
}
public function test_admin_approves_household_fires_event(): void
{
Event::fake([HouseholdVerified::class]);

View File

@@ -39,7 +39,7 @@ class MyQrCodeTest extends TestCase
{
$resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']);
$h = Household::factory()->create(['head_user_id' => $resident->id]);
$batch = app(BatchGenerator::class)->generate(5, QrCodeBatch::PURPOSE_FREE);
$batch = app(BatchGenerator::class)->generate(2, QrCodeBatch::PURPOSE_FREE);
QrCode::query()
->where('batch_id', $batch->id)
->update(['assigned_to_household_id' => $h->id, 'status' => 'active']);
@@ -47,7 +47,7 @@ class MyQrCodeTest extends TestCase
$this->getJson('/api/v1/me/qr-codes/balance')
->assertOk()
->assertJsonPath('data.active', 5)
->assertJsonPath('data.active', 2)
->assertJsonPath('data.low_balance', true);
}
@@ -111,13 +111,13 @@ class MyQrCodeTest extends TestCase
$resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']);
$h = Household::factory()->create(['head_user_id' => $resident->id]);
$batch = app(BatchGenerator::class)->generate(3, QrCodeBatch::PURPOSE_FREE);
$batch = app(BatchGenerator::class)->generate(2, QrCodeBatch::PURPOSE_FREE);
QrCode::query()->where('batch_id', $batch->id)
->update(['assigned_to_household_id' => $h->id, 'status' => 'active']);
app(QrAllocator::class)->notifyBalanceIfLow($h);
Event::assertDispatched(QrBalanceLow::class, fn ($e) => $e->household->id === $h->id && $e->activeCount === 3,
Event::assertDispatched(QrBalanceLow::class, fn ($e) => $e->household->id === $h->id && $e->activeCount === 2,
);
}
}

View File

@@ -162,7 +162,7 @@ class PartnerStoreTest extends TestCase
// Sell to household (creates sale)
$household = Household::factory()->create();
app(StoreOperations::class)->sellToHousehold($store, $household, 5, 1000);
app(StoreOperations::class)->sell($store, $household, null, 5, 1000);
// Test purchases endpoint
$response = $this->getJson("/api/v1/admin/partner-stores/{$store->uuid}/purchases");
@@ -257,7 +257,7 @@ class PartnerStoreTest extends TestCase
// 2. Sale
$household = Household::factory()->create();
app(StoreOperations::class)->sellToHousehold($store, $household, 5, 1000);
app(StoreOperations::class)->sell($store, $household, null, 5, 1000);
// 3. Defective
$qrCode = QrCode::where('assigned_to_store_id', $store->id)->first();
@@ -279,4 +279,86 @@ class PartnerStoreTest extends TestCase
$this->assertEquals('purchase', $response->json('data.3.type'));
$this->assertEquals(50, $response->json('data.3.quantity'));
}
public function test_store_partner_can_view_their_store_profile(): void
{
$owner = User::factory()->create(['role' => User::ROLE_STORE_PARTNER, 'status' => 'active']);
$store = PartnerStore::factory()->create([
'owner_user_id' => $owner->id,
'business_name' => 'My Partner Store',
'business_permit_number' => 'BP-123456',
'address_line' => '123 Store St',
'status' => 'active',
]);
Sanctum::actingAs($owner);
$response = $this->getJson('/api/v1/store/profile');
$response->assertOk()
->assertJsonPath('data.business_name', 'My Partner Store')
->assertJsonPath('data.business_permit_number', 'BP-123456')
->assertJsonPath('data.address_line', '123 Store St')
->assertJsonPath('data.owner.email', $owner->email)
->assertJsonPath('data.lgu_name', $store->tenant->name);
}
public function test_store_partner_can_update_their_store_profile(): void
{
$owner = User::factory()->create(['role' => User::ROLE_STORE_PARTNER, 'status' => 'active', 'phone' => '12345']);
$store = PartnerStore::factory()->create([
'owner_user_id' => $owner->id,
'business_name' => 'Original Store Name',
'business_permit_number' => 'BP-OLD',
'address_line' => 'Old Address',
'status' => 'active',
]);
Sanctum::actingAs($owner);
$response = $this->putJson('/api/v1/store/profile', [
'business_name' => 'New Store Name',
'business_permit_number' => 'BP-NEW',
'address_line' => 'New Address',
'operating_hours' => [
'open' => '09:00 AM',
'close' => '06:00 PM',
],
'owner_phone' => '98765',
]);
$response->assertOk();
$store->refresh();
$this->assertEquals('New Store Name', $store->business_name);
$this->assertEquals('BP-NEW', $store->business_permit_number);
$this->assertEquals('New Address', $store->address_line);
$this->assertEquals(['open' => '09:00 AM', 'close' => '06:00 PM'], $store->operating_hours);
$owner->refresh();
$this->assertEquals('98765', $owner->phone);
}
public function test_admin_can_view_distribution_charts(): void
{
Sanctum::actingAs($this->admin);
$store = PartnerStore::factory()->create(['status' => 'active']);
// Record a sale
$household = Household::factory()->create();
app(StoreOperations::class)->issueWholesale($store, 10, 50000);
app(StoreOperations::class)->sell($store, $household, null, 5, 1000);
// Test store distribution chart
$response = $this->getJson("/api/v1/admin/partner-stores/{$store->uuid}/distribution-chart?period=daily");
$response->assertOk()
->assertJsonStructure(['data' => ['period', 'labels', 'values', 'total_distributed']])
->assertJsonPath('data.total_distributed', 5);
// Test overall distribution chart
$response = $this->getJson("/api/v1/admin/partner-stores/overall-chart?period=daily");
$response->assertOk()
->assertJsonStructure(['data' => ['period', 'labels', 'values', 'total_distributed']])
->assertJsonPath('data.total_distributed', 5);
}
}