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:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
373
app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php
Normal file
373
app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
68
app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php
Normal file
68
app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
68
app/Models/QrPurchaseOrder.php
Normal file
68
app/Models/QrPurchaseOrder.php
Normal 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);
|
||||
// }
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
48
geofence-fetch.md
Normal 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
70
qr-purchase-flow.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '© 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: '© 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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
349
resources/views/store/profile.blade.php
Normal file
349
resources/views/store/profile.blade.php
Normal 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
|
||||
174
resources/views/store/qr-purchases.blade.php
Normal file
174
resources/views/store/qr-purchases.blade.php
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
428
team-reports.md
Normal 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:** ~3–5 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
|
||||
86
tests/Feature/Api/V1/Geo/FetchBoundaryTest.php
Normal file
86
tests/Feature/Api/V1/Geo/FetchBoundaryTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user