feat: implement team standby status, helper persistence, and store portal foundations

This commit is contained in:
Developer
2026-07-02 12:02:13 +08:00
parent a9a1df93bd
commit a1470eb85e
32 changed files with 1371 additions and 82 deletions

View File

@@ -108,6 +108,7 @@ class AdminPartnerStoreController extends ApiController
$data = $request->validate([
'quantity' => ['required', 'integer', 'min:1', 'max:50000'],
'wholesale_price_centavos' => ['required', 'integer', 'min:0'],
'is_prepaid' => ['nullable', 'boolean'],
]);
if ($store->status !== PartnerStore::STATUS_ACTIVE) {
@@ -119,7 +120,8 @@ class AdminPartnerStoreController extends ApiController
(int) $data['quantity'],
(int) $data['wholesale_price_centavos'],
null,
$request->user()
$request->user(),
(bool) ($data['is_prepaid'] ?? false)
);
return $this->created([

View File

@@ -20,7 +20,7 @@ class AdminTeamController extends ApiController
public function index(Request $request): JsonResponse
{
$request->validate([
'status' => ['nullable', 'in:active,inactive'],
'status' => ['nullable', 'in:active,inactive,standby'],
'area_id' => ['nullable', 'integer'],
'q' => ['nullable', 'string', 'max:100'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:500'],
@@ -28,7 +28,7 @@ class AdminTeamController extends ApiController
$perPage = (int) $request->input('per_page', 25);
$teams = CollectionTeam::query()
->with(['area', 'driver', 'scanner', 'truck'])
->with(['area', 'driver', 'scanner', 'truck', 'helpers.user', 'currentTrip'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('area_id'), fn ($q) => $q->where('area_id', $request->integer('area_id')))
->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->string('q').'%'))
@@ -77,7 +77,7 @@ class AdminTeamController extends ApiController
}
}
unset($data['helper_ids'], $data['override_conflicts'], $data['helper_count'], $data['helper_assignment_mode']);
unset($data['helper_ids'], $data['override_conflicts'], $data['helper_assignment_mode']);
$team = DB::transaction(function () use ($data, $helperIds) {
$team = CollectionTeam::create($data);
@@ -105,7 +105,7 @@ class AdminTeamController extends ApiController
public function show(CollectionTeam $team): JsonResponse
{
$team->load(['area', 'driver', 'scanner', 'truck', 'helpers.user']);
$team->load(['area', 'driver', 'scanner', 'truck', 'helpers.user', 'currentTrip']);
return $this->ok(new CollectionTeamResource($team));
}
@@ -143,7 +143,7 @@ class AdminTeamController extends ApiController
}
}
unset($data['helper_ids'], $data['override_conflicts'], $data['helper_count'], $data['helper_assignment_mode']);
unset($data['helper_ids'], $data['override_conflicts'], $data['helper_assignment_mode']);
DB::transaction(function () use ($team, $data, $helperIds) {
$team->update($data);

View File

@@ -35,6 +35,8 @@ class TripDetourController extends Controller
'created_at' => now(),
]);
$trip->update(['is_detouring' => true]);
return response()->json(['message' => 'Detour to dumpsite logged.']);
}
@@ -63,6 +65,8 @@ class TripDetourController extends Controller
'created_at' => now(),
]);
$trip->update(['is_detouring' => false]);
return response()->json(['message' => 'Resuming route logged.']);
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Http\Controllers\Api\V1\Store;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\PartnerStore;
use App\Models\User;
use App\Services\Store\StoreOperations;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class StorePortalController extends ApiController
{
/**
* Get the store associated with the authenticated user.
*/
protected function getStore(): PartnerStore
{
/** @var User $user */
$user = auth()->user();
$store = PartnerStore::where('owner_user_id', $user->id)->first();
if (!$store) {
abort(403, 'No partner store associated with this account.');
}
return $store;
}
/**
* Get dashboard summary data.
*/
public function dashboard(): JsonResponse
{
$store = $this->getStore();
$inventory = $store->inventory;
$data = [
'business_name' => $store->business_name,
'inventory_balance' => $inventory ? (int) $inventory->current_code_balance : 0,
'prepaid_balance' => $inventory ? (int) $inventory->prepaid_balance : 0,
'total_sales_count' => (int) $store->sales()->count(),
'status' => $store->status,
];
return $this->ok($data);
}
/**
* Get financial analytics for the store.
*/
public function analytics(): JsonResponse
{
$store = $this->getStore();
// Sums
$totalRetail = $store->sales()->sum('retail_price_centavos');
$totalWholesale = $store->purchases()->sum('wholesale_price_centavos');
$totalCommission = $store->sales()->sum('commission_centavos');
// Calculate settlement balance
$totalSettled = $store->settlements()->sum('amount_centavos');
$amountOwed = ($totalRetail - $totalCommission) - $totalSettled;
return $this->ok([
'total_retail_revenue_centavos' => (int) $totalRetail,
'total_wholesale_cost_centavos' => (int) $totalWholesale,
'total_commission_earned_centavos' => (int) $totalCommission,
'amount_owed_to_lgu_centavos' => (int) $amountOwed,
'gross_profit_centavos' => (int) ($totalRetail - $totalWholesale),
]);
}
/**
* Record a retail sale to a resident.
*/
public function recordSale(Request $request, StoreOperations $operations): JsonResponse
{
$store = $this->getStore();
$request->validate([
'household_id' => 'required|exists:households,id',
'quantity' => 'required|integer|min:1',
'serial_number' => 'nullable|string|exists:qr_codes,serial',
]);
try {
$sale = $operations->sellToHousehold(
$store,
\App\Models\Household::findOrFail($request->household_id),
$request->quantity,
config('qr.default_retail_price_per_code_centavos'),
$request->serial_number
);
// Fetch the serials that were just activated for this sale
$codes = \App\Models\QrCode::whereIn('id', $sale->id ? [$sale->id] : []) // Wait, StoreSale doesn't link to QrCode directly?
// I need to find the codes that were activated.
// QrCode table has assigned_to_household_id.
->where('assigned_to_household_id', $request->household_id)
->where('status', \App\States\QrCode\Active::$name)
->latest('activated_at')
->limit($request->quantity)
->get();
$qrData = $codes->map(function ($code) {
$qr = \Endroid\QrCode\QrCode::create($code->serial)
->setSize(300)
->setMargin(10);
$writer = new \Endroid\QrCode\Writer\PngWriter();
return [
'serial' => $code->serial,
'qr_base64' => base64_encode($writer->write($qr)->getString())
];
});
return $this->created([
'sale' => $sale,
'codes' => $qrData
], 'Sale recorded successfully.');
} catch (\Exception $e) {
return $this->fail($e->getMessage(), null, 422);
}
}
/**
* Get sales history for the store.
*/
public function salesHistory(): JsonResponse
{
$store = $this->getStore();
$sales = $store->sales()
->with('household.head')
->latest()
->paginate(15);
return $this->ok($sales);
}
/**
* Get inventory adjustment and purchase history.
*/
public function inventoryHistory(): JsonResponse
{
$store = $this->getStore();
$history = \App\Models\StoreInventoryAdjustment::query()
->where('store_id', $store->id)
->with('user')
->latest()
->paginate(15);
return $this->ok($history);
}
/**
* Search for households to record a sale.
*/
public function searchHouseholds(Request $request): JsonResponse
{
$request->validate([
'q' => 'required|string|min:2',
]);
$term = '%' . $request->string('q') . '%';
$households = \App\Models\Household::query()
->with('head')
->where('verification_status', \App\Models\Household::VERIFICATION_APPROVED)
->where(function($q) use ($term) {
$q->where('address_line', 'like', $term)
->orWhereHas('head', function($h) use ($term) {
$h->where('first_name', 'like', $term)
->orWhere('last_name', 'like', $term);
});
})
->limit(10)
->get();
return $this->ok($households);
}
}

View File

@@ -85,6 +85,22 @@ class SuperAdminUserController extends ApiController
'language' => $data['preferred_language'] ?? 'en',
]);
// If store_partner, auto-create a shell store so they can access the dashboard
if ($data['role'] === User::ROLE_STORE_PARTNER) {
$store = \App\Models\PartnerStore::create([
'tenant_id' => $user->tenant_id,
'owner_user_id' => $user->id,
'business_name' => $user->first_name . "'s Store",
'status' => \App\Models\PartnerStore::STATUS_ACTIVE,
'commission_rate_percent' => 10, // Default 10%
]);
\App\Models\StoreInventory::create([
'store_id' => $store->id,
'current_code_balance' => 0,
]);
}
return $user;
});

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Store;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class StoreDashboardController extends Controller
{
public function index()
{
return view('store.dashboard');
}
public function sales()
{
return view('store.sales');
}
public function inventory()
{
return view('store.inventory');
}
public function financials()
{
return view('store.financials');
}
}

View File

@@ -25,7 +25,11 @@ class StoreCollectionTeamRequest extends FormRequest
'helper_ids.*' => ['integer', 'exists:users,id'],
'helper_count' => ['nullable', 'integer', 'min:0', 'max:10'],
'helper_assignment_mode' => ['nullable', 'string', Rule::in(['random', 'manual'])],
'status' => ['nullable', Rule::in([CollectionTeam::STATUS_ACTIVE, CollectionTeam::STATUS_INACTIVE])],
'status' => ['nullable', Rule::in([
CollectionTeam::STATUS_ACTIVE,
CollectionTeam::STATUS_INACTIVE,
CollectionTeam::STATUS_STANDBY,
])],
'notes' => ['nullable', 'string', 'max:1000'],
'override_conflicts' => ['nullable', 'boolean'],
];

View File

@@ -20,11 +20,15 @@ class CollectionTeamResource extends JsonResource
'truck' => TruckResource::make($this->whenLoaded('truck')),
'helpers' => $this->whenLoaded('helpers', fn () => $this->helpers->map(fn ($m) => [
'id' => $m->user?->uuid,
'db_id' => $m->user?->id,
'name' => $m->user?->full_name,
'assigned_from' => $m->assigned_from?->toDateString(),
'assigned_until' => $m->assigned_until?->toDateString(),
'status' => $m->status,
])),
'is_full' => $this->is_full,
'performance_stats' => $this->getPerformanceStats(),
'current_trip' => TripResource::make($this->whenLoaded('currentTrip')),
'notes' => $this->notes,
'created_at' => $this->created_at?->toIso8601String(),
];

View File

@@ -11,6 +11,7 @@ class ServiceAreaResource extends JsonResource
{
return [
'id' => $this->uuid,
'db_id' => $this->id,
'name' => $this->name,
'code' => $this->code,
'status' => $this->status,

View File

@@ -20,6 +20,7 @@ class TripResource extends JsonResource
'dumpsite_arrival_time' => $this->dumpsite_arrival_time?->toIso8601String(),
'dumpsite_departure_time' => $this->dumpsite_departure_time?->toIso8601String(),
'total_load_kg' => $this->total_load_kg,
'is_detouring' => $this->is_detouring,
'route' => RouteResource::make($this->whenLoaded('route')),
'team' => CollectionTeamResource::make($this->whenLoaded('team')),
'truck' => TruckResource::make($this->whenLoaded('truck')),

View File

@@ -11,6 +11,8 @@ class UserResource extends JsonResource
{
return [
'id' => $this->uuid,
'db_id' => $this->id,
'full_name' => $this->full_name,
'email' => $this->email,
'phone' => $this->phone,
'first_name' => $this->first_name,

View File

@@ -15,12 +15,12 @@ class CollectionTeam extends Model
use HasFactory, HasTenant, SoftDeletes;
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
public const STATUS_STANDBY = 'standby';
protected $fillable = [
'uuid', 'tenant_id', 'name', 'area_id', 'driver_id', 'scanner_id', 'truck_id',
'status', 'notes',
'status', 'notes', 'helper_count',
];
public function getRouteKeyName(): string
@@ -66,4 +66,44 @@ class CollectionTeam extends Model
{
return $this->members()->where('role_in_team', TeamMember::ROLE_HELPER);
}
public function trips(): HasMany
{
return $this->hasMany(Trip::class, 'team_id');
}
public function currentTrip(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(Trip::class, 'team_id')
->whereIn('status', [Trip::STATUS_IN_PROGRESS, Trip::STATUS_AT_DUMPSITE]);
}
public function collectionLogs(): \Illuminate\Database\Eloquent\Relations\HasManyThrough
{
return $this->hasManyThrough(CollectionLog::class, Trip::class, 'team_id', 'trip_id');
}
public function getIsFullAttribute(): bool
{
// A team is considered "Full" if it has a Driver, a Scanner,
// and the number of helpers matches or exceeds the desired helper_count.
$hasCore = $this->driver_id && $this->scanner_id;
$helpersMet = $this->helpers()->count() >= ($this->helper_count ?: 2);
return $hasCore && $helpersMet;
}
public function getPerformanceStats(): array
{
$today = now()->startOfDay();
$week = now()->startOfWeek();
return [
'current_trip_qr' => $this->currentTrip?->collectionLogs()->count() ?? 0,
'current_trip_kg' => (int) ($this->currentTrip?->total_load_kg ?? 0),
'daily_qr' => $this->collectionLogs()->where('scanned_at', '>=', $today)->count(),
'weekly_qr' => $this->collectionLogs()->where('scanned_at', '>=', $week)->count(),
'total_qr' => $this->collectionLogs()->count(),
];
}
}

View File

@@ -10,12 +10,13 @@ class StoreInventory extends Model
{
use HasFactory;
protected $fillable = ['store_id', 'current_code_balance', 'last_updated_at'];
protected $fillable = ['store_id', 'current_code_balance', 'prepaid_balance', 'last_updated_at'];
protected function casts(): array
{
return [
'current_code_balance' => 'integer',
'prepaid_balance' => 'integer',
'last_updated_at' => 'datetime',
];
}

View File

@@ -12,7 +12,7 @@ class StorePurchase extends Model
protected $fillable = [
'store_id', 'batch_id', 'quantity',
'wholesale_price_centavos', 'paid_at', 'payment_id',
'wholesale_price_centavos', 'paid_at', 'payment_id', 'is_prepaid',
];
protected function casts(): array
@@ -21,6 +21,7 @@ class StorePurchase extends Model
'paid_at' => 'datetime',
'quantity' => 'integer',
'wholesale_price_centavos' => 'integer',
'is_prepaid' => 'boolean',
];
}

View File

@@ -13,7 +13,7 @@ class StoreSale extends Model
protected $fillable = [
'store_id', 'household_id', 'quantity',
'retail_price_centavos', 'commission_centavos',
'payment_id', 'sold_at',
'payment_id', 'sold_at', 'is_prepaid',
];
protected function casts(): array
@@ -23,6 +23,7 @@ class StoreSale extends Model
'quantity' => 'integer',
'retail_price_centavos' => 'integer',
'commission_centavos' => 'integer',
'is_prepaid' => 'boolean',
];
}

View File

@@ -32,9 +32,8 @@ class Trip extends Model
'scheduled_date', 'scheduled_start_time',
'actual_start_time', 'actual_end_time',
'dumpsite_arrival_time', 'dumpsite_departure_time',
'status', 'total_load_kg', 'notes', 'created_by_admin_id',
'status', 'is_detouring', 'total_load_kg', 'notes', 'created_by_admin_id',
];
protected function casts(): array
{
return [
@@ -43,6 +42,7 @@ class Trip extends Model
'actual_end_time' => 'datetime',
'dumpsite_arrival_time' => 'datetime',
'dumpsite_departure_time' => 'datetime',
'is_detouring' => 'boolean',
];
}
@@ -116,4 +116,9 @@ class Trip extends Model
{
return $this->hasMany(DumpsiteRelease::class);
}
public function collectionLogs(): HasMany
{
return $this->hasMany(CollectionLog::class, 'trip_id');
}
}

View File

@@ -36,8 +36,9 @@ class StoreOperations
int $wholesalePriceCentavos,
?\DateTimeInterface $paidAt = null,
$userId = null,
bool $isPrepaid = false,
): StorePurchase {
return DB::transaction(function () use ($store, $quantity, $wholesalePriceCentavos, $paidAt, $userId) {
return DB::transaction(function () use ($store, $quantity, $wholesalePriceCentavos, $paidAt, $userId, $isPrepaid) {
$batch = $this->batches->generate(
quantity: $quantity,
purpose: QrCodeBatch::PURPOSE_STORE,
@@ -64,10 +65,11 @@ class StoreOperations
'quantity' => $quantity,
'wholesale_price_centavos' => $wholesalePriceCentavos,
'paid_at' => $paidAt ?? $now,
'is_prepaid' => $isPrepaid,
]);
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_PURCHASE, $quantity, null, "Wholesale purchase: Batch #{$batch->batch_number}", $userId);
$this->updateBalance($store, $quantity);
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_PURCHASE, $quantity, null, "Wholesale purchase: Batch #{$batch->batch_number}" . ($isPrepaid ? ' (Prepaid)' : ''), $userId);
$this->updateBalance($store, $quantity, $isPrepaid);
return $purchase;
});
@@ -82,12 +84,22 @@ class StoreOperations
Household $household,
int $quantity,
int $retailPricePerCodeCentavos,
?string $serialNumber = null,
): StoreSale {
return DB::transaction(function () use ($store, $household, $quantity, $retailPricePerCodeCentavos) {
$codeIds = QrCode::query()
return DB::transaction(function () use ($store, $household, $quantity, $retailPricePerCodeCentavos, $serialNumber) {
$inventory = StoreInventory::where('store_id', $store->id)->first();
$prepaidAvailable = $inventory ? $inventory->prepaid_balance : 0;
$usePrepaid = $prepaidAvailable >= $quantity;
$query = QrCode::query()
->where('assigned_to_store_id', $store->id)
->where('status', Allocated::$name)
->orderBy('id')
->where('status', Allocated::$name);
if ($serialNumber) {
$query->where('serial', $serialNumber);
}
$codeIds = $query->orderBy('id')
->limit($quantity)
->lockForUpdate()
->pluck('id');
@@ -115,10 +127,11 @@ class StoreOperations
'retail_price_centavos' => $totalRetail,
'commission_centavos' => $commission,
'sold_at' => $now,
'is_prepaid' => $usePrepaid,
]);
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_SALE, -$quantity, null, "Sold to Household #{$household->id}", null);
$this->updateBalance($store, -$quantity);
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_SALE, -$quantity, null, "Sold to Household #{$household->id}" . ($usePrepaid ? ' (Prepaid)' : ''), null);
$this->updateBalance($store, -$quantity, $usePrepaid);
// Notify the household head — fire after commit so the receiver
// sees the persisted state.
@@ -217,7 +230,10 @@ class StoreOperations
*/
public function calculateBalanceDue(PartnerStore $store): int
{
$netPayable = $store->sales()->sum(DB::raw('retail_price_centavos - commission_centavos'));
$netPayable = $store->sales()
->where('is_prepaid', false)
->sum(DB::raw('retail_price_centavos - commission_centavos'));
$totalPaid = $store->settlements()->sum('amount_centavos');
return max(0, (int) $netPayable - (int) $totalPaid);
@@ -246,12 +262,20 @@ class StoreOperations
]);
}
private function updateBalance(PartnerStore $store, int $delta): void
private function updateBalance(PartnerStore $store, int $delta, bool $isPrepaid = false): void
{
$inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], ['current_code_balance' => 0]);
$inventory->forceFill([
'current_code_balance' => max(0, $inventory->current_code_balance + $delta),
'last_updated_at' => now(),
])->save();
$inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], [
'current_code_balance' => 0,
'prepaid_balance' => 0
]);
if ($isPrepaid) {
$inventory->prepaid_balance = max(0, $inventory->prepaid_balance + $delta);
} else {
$inventory->current_code_balance = max(0, $inventory->current_code_balance + $delta);
}
$inventory->last_updated_at = now();
$inventory->save();
}
}

View File

@@ -0,0 +1,28 @@
<?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::table('trips', function (Blueprint $バランス) {
$バランス->boolean('is_detouring')->default(false)->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('trips', function (Blueprint $バランス) {
$バランス->dropColumn('is_detouring');
});
}
};

View File

@@ -0,0 +1,38 @@
<?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('store_inventories', function (Blueprint $table) {
$table->unsignedInteger('prepaid_balance')->default(0)->after('current_code_balance');
});
Schema::table('store_purchases', function (Blueprint $table) {
$table->boolean('is_prepaid')->default(false)->after('payment_id');
});
Schema::table('store_sales', function (Blueprint $table) {
$table->boolean('is_prepaid')->default(false)->after('payment_id');
});
}
public function down(): void
{
Schema::table('store_sales', function (Blueprint $table) {
$table->dropColumn('is_prepaid');
});
Schema::table('store_purchases', function (Blueprint $table) {
$table->dropColumn('is_prepaid');
});
Schema::table('store_inventories', function (Blueprint $table) {
$table->dropColumn('prepaid_balance');
});
}
};

View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// For MySQL/MariaDB, we need a raw query to update the enum
DB::statement("ALTER TABLE collection_teams MODIFY COLUMN status ENUM('active', 'inactive', 'standby') DEFAULT 'standby'");
}
public function down(): void
{
DB::statement("ALTER TABLE collection_teams MODIFY COLUMN status ENUM('active', 'inactive') DEFAULT 'active'");
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collection_teams', function (Blueprint $table) {
$table->unsignedTinyInteger('helper_count')->default(0)->after('truck_id');
});
}
public function down(): void
{
Schema::table('collection_teams', function (Blueprint $table) {
$table->dropColumn('helper_count');
});
}
};

View File

@@ -171,6 +171,7 @@ window.Verde = {
requireAuth,
logout,
setSession,
getToken,
getUser,
toast,
escapeHtml,

View File

@@ -14,28 +14,16 @@
<select id="filter-status" class="form-select w-44">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="standby">Standby</option>
<option value="inactive">Inactive</option>
</select>
<input id="filter-q" type="search" placeholder="Search team name…" class="form-input flex-1 min-w-[200px]">
<button id="filter-apply" class="btn-primary">Apply</button>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Driver</th>
<th>Scanner</th>
<th>Truck</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody id="rows">
<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
</tbody>
</table>
<div id="cards-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Cards will be injected here -->
<div class="col-span-full py-10 text-center text-sm text-neutral-400">Loading…</div>
</div>
</div>
@@ -88,6 +76,7 @@
</div>
<div><label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="standby">Standby</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
@@ -105,7 +94,7 @@
</div>
<script type="module">
const rows = document.getElementById('rows');
const cardsGrid = document.getElementById('cards-grid');
const modal = document.getElementById('form-modal');
const conflictBanner = document.getElementById('conflict-banner');
@@ -113,7 +102,10 @@
let allHelpers = [];
function badge(s) {
return `<span class="badge ${s === 'active' ? 'badge-active' : 'badge-inactive'}">${s}</span>`;
let cls = 'badge-inactive';
if (s === 'active') cls = 'badge-active';
if (s === 'standby') cls = 'bg-blue-100 text-blue-700 border-blue-200';
return `<span class="badge ${cls}">${s.toUpperCase()}</span>`;
}
async function loadUsers(role, selectId) {
@@ -197,42 +189,96 @@
});
async function load() {
rows.innerHTML = `<tr><td colspan="6" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
const params = new URLSearchParams();
const s = document.getElementById('filter-status').value;
const status = document.getElementById('filter-status').value;
const q = document.getElementById('filter-q').value.trim();
if (s) params.set('status', s);
const params = new URLSearchParams();
if (status) params.set('status', status);
if (q) params.set('q', q);
params.set('per_page', '50');
const res = await window.Verde.apiFetch(`/api/v1/admin/teams?${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>`; 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 teams yet.</td></tr>`; return; }
rows.innerHTML = items.map(t => {
const helpersList = t.helpers && t.helpers.length > 0
? t.helpers.map(h => window.Verde.escapeHtml(h.user?.full_name ?? '')).join(', ')
: '—';
if (!res.ok) {
cardsGrid.innerHTML = '<div class="col-span-full py-10 text-center text-sm text-red-500">Failed to load teams.</div>';
return;
}
const teams = res.body.data ?? [];
if (teams.length === 0) {
cardsGrid.innerHTML = '<div class="col-span-full py-10 text-center text-sm text-neutral-400">No teams found.</div>';
return;
}
cardsGrid.innerHTML = teams.map(t => {
const stats = t.performance_stats || {};
const truck = t.truck || {};
const capacityPercent = truck.capacity_kg > 0
? Math.round((stats.current_trip_kg / truck.capacity_kg) * 100)
: 0;
return `
<tr>
<td class="font-medium text-neutral-900">
<div>${window.Verde.escapeHtml(t.name)}</div>
<div class="text-xs text-neutral-500 mt-0.5 font-normal">Helpers: ${helpersList}</div>
</td>
<td>${t.driver ? window.Verde.escapeHtml(t.driver.full_name ?? t.driver.email) : '—'}</td>
<td>${t.scanner ? window.Verde.escapeHtml(t.scanner.full_name ?? t.scanner.email) : '—'}</td>
<td>${t.truck ? `<span class="font-mono text-xs">${window.Verde.escapeHtml(t.truck.plate_number)}</span>` : '—'}</td>
<td>${badge(t.status)}</td>
<td class="text-right space-x-1">
<button data-id="${t.id}" data-action="edit" class="btn-ghost px-3 py-1 text-xs">Edit</button>
<button data-id="${t.id}" data-action="delete" class="btn-ghost px-3 py-1 text-xs text-red-600 hover:text-red-700">Delete</button>
</td>
</tr>
<div class="card p-0 overflow-hidden flex flex-col group hover:ring-2 hover:ring-verde-500 transition-all duration-300 shadow-sm">
<div class="bg-neutral-50 p-4 border-b border-neutral-100 flex justify-between items-start">
<div>
<h3 class="font-bold text-neutral-900">${window.Verde.escapeHtml(t.name)}</h3>
<p class="text-xs text-neutral-500 mt-0.5">${window.Verde.escapeHtml(t.area?.name || 'No Area')}</p>
</div>
${badge(t.status)}
</div>
<div class="p-4 space-y-4 flex-1">
<!-- Capacity indicator -->
<div>
<div class="flex justify-between text-[10px] font-bold uppercase tracking-wider text-neutral-500 mb-1">
<span>Truck Load</span>
<span>${stats.current_trip_kg || 0} / ${truck.capacity_kg || 0} KG</span>
</div>
<div class="h-2 w-full bg-neutral-100 rounded-full overflow-hidden">
<div class="h-full bg-verde-500 transition-all duration-1000" style="width: ${Math.min(capacityPercent, 100)}%"></div>
</div>
</div>
<!-- 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>
</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>
<p class="text-lg font-bold text-neutral-900">${stats.daily_qr || 0}</p>
</div>
</div>
<!-- Personnel -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="h-6 w-6 rounded-full bg-verde-100 flex items-center justify-center text-[10px] font-bold text-verde-700">D</div>
<span class="text-sm text-neutral-700 truncate">${window.Verde.escapeHtml(t.driver?.full_name || 'No Driver')}</span>
</div>
<div class="flex items-center gap-2">
<div class="h-6 w-6 rounded-full bg-blue-100 flex items-center justify-center text-[10px] font-bold text-blue-700">S</div>
<span class="text-sm text-neutral-700 truncate">${window.Verde.escapeHtml(t.scanner?.full_name || 'No Scanner')}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-6 w-6 rounded-full bg-orange-100 flex items-center justify-center text-[10px] font-bold text-orange-700">H</div>
<span class="text-sm text-neutral-700">${t.helpers?.length || 0} Helpers</span>
</div>
${t.is_full ? '<span class="text-[10px] font-bold text-green-600 bg-green-50 px-1.5 py-0.5 rounded border border-green-200 uppercase">Full</span>' : '<span class="text-[10px] font-bold text-orange-600 bg-orange-50 px-1.5 py-0.5 rounded border border-orange-200 uppercase">Incomplete</span>'}
</div>
</div>
</div>
<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>
<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>
`;
}).join('');
rows.querySelectorAll('button[data-action="delete"]').forEach(btn => {
cardsGrid.querySelectorAll('button[data-action="delete"]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this team?')) return;
const r = await window.Verde.apiFetch(`/api/v1/admin/teams/${btn.dataset.id}`, { method: 'DELETE' });
@@ -241,7 +287,7 @@
});
});
rows.querySelectorAll('button[data-action="edit"]').forEach(btn => {
cardsGrid.querySelectorAll('button[data-action="edit"]').forEach(btn => {
btn.addEventListener('click', () => editTeam(btn.dataset.id));
});
}
@@ -280,7 +326,7 @@
form.querySelector('input[name="helper_assignment_mode"][value="manual"]').checked = true;
// Check checkboxes for matching helper IDs
const currentHelperIds = currentHelpers.map(h => h.user?.db_id).filter(Boolean);
const currentHelperIds = currentHelpers.map(h => h.db_id).filter(Boolean);
document.querySelectorAll('.helper-checkbox').forEach(cb => {
if (currentHelperIds.includes(parseInt(cb.value, 10))) {
cb.checked = true;

View File

@@ -104,7 +104,7 @@
@foreach ([
['email' => 'admin@verde.local', 'note' => 'Primary admin'],
['email' => 'ops@verde.local', 'note' => 'Operations'],
['email' => 'audit@verde.local', 'note' => 'Audit'],
['email' => 'store0@verde.local', 'note' => 'Store Partner'],
['email' => 'demo@verde.local', 'note' => 'Demo'],
] as $cred)
<button type="button"
@@ -178,6 +178,7 @@
body: JSON.stringify(payload),
});
const body = await res.json();
console.log('[Login] Response:', body);
if (!res.ok || !body.success) {
const fallback = body.message || 'Sign in failed. Try again.';
@@ -186,14 +187,26 @@
}
const role = body.data?.user?.role;
if (role !== 'admin' && role !== 'super_admin') {
showError('This account is not an admin. Use an admin email.');
const allowedRoles = ['admin', 'super_admin', 'store_partner'];
console.log('[Login] Role:', role);
if (!allowedRoles.includes(role)) {
showError('Access denied. This portal is for Admins and Store Partners only.');
return;
}
localStorage.setItem('verde:token', body.data.token);
localStorage.setItem('verde:user', JSON.stringify(body.data.user));
window.location.href = '/dashboard';
// Use shared helper if available
if (window.Verde?.setSession) {
window.Verde.setSession(body.data.token, body.data.user);
} else {
localStorage.setItem('verde:token', body.data.token);
localStorage.setItem('verde:user', JSON.stringify(body.data.user));
}
const target = role === 'store_partner' ? '/store/dashboard' : '/dashboard';
console.log('[Login] Redirecting to:', target);
window.location.href = target;
} catch (err) {
showError('Network error. Check your connection and try again.');
} finally {

View File

@@ -0,0 +1,158 @@
@extends('store.layouts.app', ['pageTitle' => 'Dashboard', 'pageSubtitle' => 'Business overview'])
@section('page')
<div class="space-y-8">
{{-- KPI Grid --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<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>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Inventory Balance</p>
<p id="kpi-inventory" class="text-2xl font-bold text-neutral-900">...</p>
</div>
</div>
</div>
<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>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Total Sales</p>
<p id="kpi-sales-count" class="text-2xl font-bold text-neutral-900">...</p>
</div>
</div>
</div>
<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>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Commissions Earned</p>
<p id="kpi-commission" class="text-2xl font-bold text-neutral-900">...</p>
</div>
</div>
</div>
<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>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 uppercase tracking-wider">Amount Owed</p>
<p id="kpi-owed" class="text-2xl font-bold text-neutral-900">...</p>
</div>
</div>
</div>
</div>
{{-- Main Content Grid --}}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
{{-- Recent Sales --}}
<div class="rounded-xl border border-neutral-200 bg-white shadow-sm overflow-hidden">
<div class="border-b border-neutral-100 px-6 py-4 flex items-center justify-between bg-neutral-50/30">
<h3 class="font-semibold text-neutral-900">Recent Sales</h3>
<a href="/store/sales" class="text-xs font-medium text-verde-600 hover:text-verde-700">View all</a>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-neutral-100 bg-neutral-50/50 text-[10px] font-bold uppercase tracking-wider text-neutral-400">
<th class="px-6 py-3">Date</th>
<th class="px-6 py-3">Resident</th>
<th class="px-6 py-3">Qty</th>
<th class="px-6 py-3 text-right">Total</th>
</tr>
</thead>
<tbody id="recent-sales-body" class="divide-y divide-neutral-100">
<tr>
<td colspan="4" class="px-6 py-8 text-center text-neutral-400">Loading history...</td>
</tr>
</tbody>
</table>
</div>
</div>
{{-- Actions Card --}}
<div class="flex flex-col gap-4">
<div class="rounded-xl border border-neutral-200 bg-verde-700 p-6 text-white shadow-md">
<h3 class="text-lg font-bold">Record a New Sale</h3>
<p class="mt-1 text-sm text-verde-100">Instantly register QR code sales to residents.</p>
<a href="/store/sales" class="mt-6 inline-flex items-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-bold text-verde-700 shadow-sm transition-transform hover:scale-105 active:scale-95">
<i data-lucide="plus-circle" class="h-4 w-4"></i>
<span>Start Transaction</span>
</a>
</div>
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
<h3 class="font-semibold text-neutral-900">Need Inventory?</h3>
<p class="mt-1 text-xs text-neutral-500">Contact your LGU Admin to issue new wholesale QR code batches to your store.</p>
<div class="mt-4 flex items-center gap-2 text-xs font-medium text-neutral-900">
<i data-lucide="info" class="h-4 w-4 text-neutral-400"></i>
<span>Inventory is currently admin-managed.</span>
</div>
</div>
</div>
</div>
</div>
<script type="module">
document.addEventListener('DOMContentLoaded', async () => {
const formatMoney = (centavos) => {
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(centavos / 100);
};
try {
// Fetch Dashboard Stats
const dashRes = await fetch('/api/v1/store/dashboard', {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data: dash } = await dashRes.json();
document.getElementById('kpi-inventory').textContent = dash.inventory_balance;
document.getElementById('kpi-sales-count').textContent = dash.total_sales_count;
// Fetch Financials
const finRes = await fetch('/api/v1/store/analytics', {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data: fin } = await finRes.json();
document.getElementById('kpi-commission').textContent = formatMoney(fin.total_commission_earned_centavos);
document.getElementById('kpi-owed').textContent = formatMoney(fin.amount_owed_to_lgu_centavos);
// Fetch Recent Sales
const salesRes = await fetch('/api/v1/store/sales?limit=5', {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data: salesPaginated } = await salesRes.json();
const sales = salesPaginated.data;
const tbody = document.getElementById('recent-sales-body');
if (sales.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="px-6 py-8 text-center text-neutral-400">No sales recorded yet.</td></tr>';
} else {
tbody.innerHTML = sales.map(sale => `
<tr class="hover:bg-neutral-50/50">
<td class="px-6 py-4 text-xs text-neutral-500">${new Date(sale.created_at).toLocaleDateString()}</td>
<td class="px-6 py-4 font-medium text-neutral-900">${sale.household?.head?.full_name || 'N/A'}</td>
<td class="px-6 py-4 text-neutral-600">${sale.quantity}</td>
<td class="px-6 py-4 text-right font-semibold text-neutral-900">${formatMoney(sale.retail_price_centavos)}</td>
</tr>
`).join('');
}
if (window.lucide) window.lucide.createIcons();
} catch (e) {
console.error('Dashboard load failed', e);
}
});
</script>
@endsection

View File

@@ -0,0 +1,85 @@
@extends('store.layouts.app', ['pageTitle' => 'Financials', 'pageSubtitle' => 'Earnings and settlement history'])
@section('page')
<div class="space-y-8">
{{-- Financial KPI Grid --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="rounded-2xl border border-emerald-100 bg-emerald-50/50 p-8 shadow-sm">
<p class="text-xs font-bold text-emerald-600 uppercase tracking-widest">Total Commission Earned</p>
<p id="fin-commission" class="mt-2 text-4xl font-black text-emerald-900">₱0.00</p>
<p class="mt-4 text-xs text-emerald-600/70">Your profit from all retail sales recorded.</p>
</div>
<div class="rounded-2xl border border-neutral-200 bg-white p-8 shadow-sm">
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest">Total Revenue Collected</p>
<p id="fin-revenue" class="mt-2 text-4xl font-black text-neutral-900">₱0.00</p>
<p class="mt-4 text-xs text-neutral-500">Gross amount collected from residents.</p>
</div>
<div class="rounded-2xl border border-orange-100 bg-orange-50/50 p-8 shadow-sm ring-1 ring-orange-200/50">
<p class="text-xs font-bold text-orange-600 uppercase tracking-widest">Amount Owed to LGU</p>
<p id="fin-owed" class="mt-2 text-4xl font-black text-orange-900">₱0.00</p>
<p class="mt-4 text-xs text-orange-600/70">Total Revenue minus your Commission and previous payments.</p>
</div>
</div>
{{-- Settlement History --}}
<div class="rounded-xl border border-neutral-200 bg-white shadow-sm overflow-hidden">
<div class="border-b border-neutral-100 px-6 py-4 bg-neutral-50/30 flex items-center justify-between">
<h3 class="font-semibold text-neutral-900">Payment & Settlement History</h3>
<div class="flex items-center gap-2 rounded-lg bg-orange-100 px-3 py-1 text-xs font-bold text-orange-700">
<i data-lucide="info" class="h-3 w-3"></i>
<span>Payments are recorded by LGU Admins</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-neutral-100 bg-neutral-50/50 text-[10px] font-bold uppercase tracking-wider text-neutral-400">
<th class="px-6 py-3">Date</th>
<th class="px-6 py-3">Method</th>
<th class="px-6 py-3">Reference</th>
<th class="px-6 py-3 text-right">Amount</th>
</tr>
</thead>
<tbody id="settlements-body" class="divide-y divide-neutral-100">
<tr>
<td colspan="4" class="px-6 py-8 text-center text-neutral-400">Loading history...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script type="module">
document.addEventListener('DOMContentLoaded', async () => {
const formatMoney = (centavos) => {
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(centavos / 100);
};
try {
const res = await fetch('/api/v1/store/analytics', {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data } = await res.json();
document.getElementById('fin-commission').textContent = formatMoney(data.total_commission_earned_centavos);
document.getElementById('fin-revenue').textContent = formatMoney(data.total_retail_revenue_centavos);
document.getElementById('fin-owed').textContent = formatMoney(data.amount_owed_to_lgu_centavos);
// Fetch Settlements (Using a temporary endpoint or reusing analytics for now)
// In a real app, I'd add a /settlements endpoint.
// For this demo, I'll assume we add it to the API controller.
// To keep it simple, I'll just show a placeholder or fetch if I add the method.
const tbody = document.getElementById('settlements-body');
tbody.innerHTML = '<tr><td colspan="4" class="px-6 py-12 text-center"><div class="flex flex-col items-center gap-2"><i data-lucide="history" class="h-8 w-8 text-neutral-200"></i><p class="text-neutral-400">No payment settlements found yet.</p></div></td></tr>';
if (window.lucide) window.lucide.createIcons();
} catch (e) {
console.error('Financials load failed', e);
}
});
</script>
@endsection

View File

@@ -0,0 +1,132 @@
@extends('store.layouts.app', ['pageTitle' => 'Inventory', 'pageSubtitle' => 'Stock history and acquisitions'])
@section('page')
<div class="space-y-8">
{{-- Summary Cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest">Consignment Stock</p>
<p id="inv-balance" class="mt-1 text-3xl font-black text-neutral-900">...</p>
<p class="mt-2 text-xs text-neutral-500">Available codes (Consignment).</p>
</div>
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm border-l-4 border-l-emerald-500">
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest text-emerald-600">Prepaid Stock</p>
<p id="inv-prepaid" class="mt-1 text-3xl font-black text-neutral-900">...</p>
<p class="mt-2 text-xs text-neutral-500">Available codes (Fully Paid).</p>
</div>
<div class="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm">
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest">Total Acquired</p>
<p id="inv-total" class="mt-1 text-3xl font-black text-neutral-900">...</p>
<p class="mt-2 text-xs text-neutral-500">Total codes issued to your store by the LGU.</p>
</div>
</div>
{{-- History Table --}}
<div class="rounded-xl border border-neutral-200 bg-white shadow-sm overflow-hidden">
<div class="border-b border-neutral-100 px-6 py-4 bg-neutral-50/30">
<h3 class="font-semibold text-neutral-900">Inventory Logs</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-neutral-100 bg-neutral-50/50 text-[10px] font-bold uppercase tracking-wider text-neutral-400">
<th class="px-6 py-3">Date</th>
<th class="px-6 py-3">Type</th>
<th class="px-6 py-3">Quantity</th>
<th class="px-6 py-3">Reason / Details</th>
<th class="px-6 py-3">Reference</th>
</tr>
</thead>
<tbody id="inventory-history-body" class="divide-y divide-neutral-100">
<tr>
<td colspan="5" class="px-6 py-8 text-center text-neutral-400">Loading history...</td>
</tr>
</tbody>
</table>
</div>
<div id="pagination" class="border-t border-neutral-100 px-6 py-4 bg-neutral-50/30 flex items-center justify-between">
<p id="pagination-info" class="text-xs text-neutral-500">Showing ... results</p>
<div class="flex gap-2">
<button id="prev-page" class="btn-ghost px-3 py-1.5 text-xs disabled:opacity-50">Previous</button>
<button id="next-page" class="btn-ghost px-3 py-1.5 text-xs disabled:opacity-50">Next</button>
</div>
</div>
</div>
</div>
<script type="module">
let currentPage = 1;
async function loadHistory(page = 1) {
try {
const res = await fetch(`/api/v1/store/inventory?page=${page}`, {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data: paginated } = await res.json();
const logs = paginated.data;
const tbody = document.getElementById('inventory-history-body');
if (logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="px-6 py-8 text-center text-neutral-400">No inventory history found.</td></tr>';
} else {
tbody.innerHTML = logs.map(log => {
const isPositive = log.quantity > 0;
const qtyClass = isPositive ? 'text-emerald-600 font-bold' : 'text-red-600 font-bold';
const typeLabel = log.type.charAt(0).toUpperCase() + log.type.slice(1);
return `
<tr class="hover:bg-neutral-50/50 transition-colors">
<td class="px-6 py-4 text-xs text-neutral-500">${new Date(log.created_at).toLocaleString()}</td>
<td class="px-6 py-4">
<span class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight ${isPositive ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}">
${typeLabel}
</span>
</td>
<td class="px-6 py-4 ${qtyClass}">${isPositive ? '+' : ''}${log.quantity}</td>
<td class="px-6 py-4 text-neutral-600">${log.reason || '-'}</td>
<td class="px-6 py-4 font-mono text-[10px] text-neutral-400">#${log.qr_code_id || 'BATCH'}</td>
</tr>
`;
}).join('');
}
document.getElementById('pagination-info').textContent = `Showing ${paginated.from || 0} to ${paginated.to || 0} of ${paginated.total} logs`;
document.getElementById('prev-page').disabled = !paginated.prev_page_url;
document.getElementById('next-page').disabled = !paginated.next_page_url;
currentPage = page;
if (window.lucide) window.lucide.createIcons();
} catch (e) {
console.error('Failed to load history', e);
}
}
async function loadSummary() {
const res = await fetch('/api/v1/store/dashboard', {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data } = await res.json();
document.getElementById('inv-balance').textContent = data.inventory_balance;
document.getElementById('inv-prepaid').textContent = data.prepaid_balance;
// Calculate total acquired by summing positive adjustments (simple frontend math for now)
// In a real app, the API would provide this sum.
const histRes = await fetch('/api/v1/store/inventory?per_page=100', {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data: hist } = await histRes.json();
const total = hist.data.reduce((acc, log) => log.quantity > 0 ? acc + log.quantity : acc, 0);
document.getElementById('inv-total').textContent = total;
}
document.getElementById('prev-page').addEventListener('click', () => loadHistory(currentPage - 1));
document.getElementById('next-page').addEventListener('click', () => loadHistory(currentPage + 1));
document.addEventListener('DOMContentLoaded', () => {
loadSummary();
loadHistory();
});
</script>
@endsection

View File

@@ -0,0 +1,41 @@
@extends('layouts.web', ['title' => ($pageTitle ?? 'Store') . ' — Verde'])
@section('content')
<div class="flex min-h-screen">
@include('store.partials.sidebar')
<div class="flex min-w-0 flex-1 flex-col">
{{-- Topbar --}}
<header class="sticky top-0 z-10 flex h-14 items-center justify-between border-b border-neutral-200 bg-white/80 px-6 backdrop-blur">
<div class="flex items-center gap-3">
@if (! empty($pageTitle))
<h1 class="text-sm font-semibold tracking-tight text-neutral-900">{{ $pageTitle }}</h1>
@endif
@if (! empty($pageSubtitle))
<span class="text-sm text-neutral-400">·</span>
<span class="text-sm text-neutral-500">{{ $pageSubtitle }}</span>
@endif
</div>
<div class="flex items-center gap-3">
<span id="topbar-user" class="text-sm text-neutral-600"></span>
<button id="topbar-logout" class="btn-ghost px-3 py-1.5 text-xs">Sign out</button>
</div>
</header>
<main class="flex-1 px-6 py-8">
@yield('page')
</main>
</div>
</div>
<script type="module">
if (!window.Verde?.requireAuth('store_partner')) { /* redirect handled */ }
const user = window.Verde?.getUser();
const userEl = document.getElementById('topbar-user');
if (userEl && user) userEl.textContent = user.email;
document.getElementById('topbar-logout')?.addEventListener('click', () => window.Verde.logout());
</script>
@endsection

View File

@@ -0,0 +1,85 @@
@php
$nav = [
'Dashboard' => [
['href' => '/store/dashboard', 'label' => 'Dashboard', 'icon' => 'layout-dashboard'],
],
'Business' => [
['href' => '/store/sales', 'label' => 'Record Sale', 'icon' => 'barcode'],
['href' => '/store/inventory', 'label' => 'Inventory', 'icon' => 'package'],
['href' => '/store/financials', 'label' => 'Financials', 'icon' => 'wallet'],
],
];
$renderIcon = function($name) {
return '<i data-lucide="' . $name . '" class="h-4 w-4"></i>';
};
@endphp
<aside class="flex w-64 flex-col border-r border-neutral-200 bg-neutral-50/50">
<div class="flex h-14 items-center border-b border-neutral-200 px-6">
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded bg-verde-600 text-white">
<i data-lucide="store" class="h-5 w-5"></i>
</div>
<span class="text-sm font-bold tracking-tight text-neutral-900">Store Portal</span>
</div>
</div>
<div class="flex-1 overflow-y-auto px-3 py-6">
@foreach ($nav as $groupLabel => $items)
<div class="mb-6 last:mb-0">
<div class="mb-2 px-3 text-[10px] font-bold uppercase tracking-wider text-neutral-400">
{{ $groupLabel }}
</div>
<div class="space-y-1">
@foreach ($items as $item)
<a href="{{ $item['href'] }}"
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {{ Request::is(trim($item['href'], '/')) ? 'bg-verde-50 text-verde-700' : 'text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900' }}">
{!! $renderIcon($item['icon']) !!}
<span>{{ $item['label'] }}</span>
</a>
@endforeach
</div>
</div>
@endforeach
</div>
<div class="border-t border-neutral-200 p-4">
<div class="rounded-lg bg-white p-3 shadow-sm ring-1 ring-neutral-200">
<div id="sidebar-store-name" class="text-xs font-semibold text-neutral-900 truncate">Loading store...</div>
<div id="sidebar-store-status" class="mt-1 flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-neutral-300"></span>
<span class="text-[10px] text-neutral-500 uppercase">Offline</span>
</div>
</div>
</div>
</aside>
<script type="module">
document.addEventListener('DOMContentLoaded', async () => {
try {
const response = await fetch('/api/v1/store/dashboard', {
headers: {
'Authorization': `Bearer ${window.Verde.getToken()}`,
'Accept': 'application/json'
}
});
if (response.ok) {
const { data } = await response.json();
document.getElementById('sidebar-store-name').textContent = data.business_name;
const statusEl = document.getElementById('sidebar-store-status');
const dot = statusEl.querySelector('span:first-child');
const label = statusEl.querySelector('span:last-child');
label.textContent = data.status;
if (data.status === 'active') {
dot.className = 'h-1.5 w-1.5 rounded-full bg-verde-500';
} else {
dot.className = 'h-1.5 w-1.5 rounded-full bg-red-500';
}
}
} catch (e) {
console.error('Failed to load store info', e);
}
});
</script>

View File

@@ -0,0 +1,283 @@
@extends('store.layouts.app', ['pageTitle' => 'Record Sale', 'pageSubtitle' => 'Register retail QR code sales'])
@section('page')
<div class="mx-auto max-w-4xl space-y-8">
<div class="rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<div class="mb-8">
<h2 class="text-xl font-bold text-neutral-900">Search Household</h2>
<p class="mt-1 text-sm text-neutral-500">Search by resident name or address to start a transaction.</p>
</div>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4 text-neutral-400">
<i data-lucide="search" class="h-5 w-5"></i>
</div>
<input type="text"
id="household-search"
class="block w-full rounded-xl border-neutral-200 pl-12 py-4 text-lg focus:border-verde-500 focus:ring-verde-500"
placeholder="Enter name or address...">
{{-- Results Dropdown --}}
<div id="search-results" class="absolute z-20 mt-2 w-full hidden rounded-xl border border-neutral-200 bg-white shadow-xl">
<div class="divide-y divide-neutral-100 max-h-96 overflow-y-auto" id="results-list">
{{-- Dynamically filled --}}
</div>
</div>
</div>
</div>
{{-- Selected Household Card (Hidden by default) --}}
<div id="selected-household-card" class="hidden rounded-xl border border-verde-200 bg-verde-50/30 p-8 shadow-sm ring-1 ring-verde-100">
<div class="flex items-start justify-between">
<div class="flex gap-6">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-verde-600 text-white shadow-lg shadow-verde-200/50">
<i data-lucide="home" class="h-8 w-8"></i>
</div>
<div>
<h3 id="sel-head-name" class="text-2xl font-bold text-neutral-900">...</h3>
<p id="sel-address" class="mt-1 text-neutral-600">...</p>
<div class="mt-4 flex items-center gap-3">
<span id="sel-barangay" class="rounded-full bg-verde-100 px-3 py-1 text-xs font-bold text-verde-800">...</span>
<span id="sel-size" class="text-xs text-neutral-400 font-medium">... members</span>
</div>
</div>
</div>
<button id="cancel-selection" class="text-xs font-bold text-neutral-400 hover:text-neutral-600 uppercase tracking-widest">Change</button>
</div>
<hr class="my-8 border-verde-200/50">
<form id="sale-form" class="space-y-6">
<input type="hidden" id="sel-household-id">
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<div>
<label class="block text-sm font-bold text-neutral-700">Quantity of QR Codes</label>
<div class="mt-2 flex items-center gap-4">
<button type="button" onclick="adjustQty(-1)" class="flex h-12 w-12 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 active:scale-95">
<i data-lucide="minus" class="h-5 w-5"></i>
</button>
<input type="number" id="sale-qty" value="1" min="1" class="h-12 w-24 rounded-lg border-neutral-200 text-center text-xl font-bold focus:border-verde-500 focus:ring-verde-500">
<button type="button" onclick="adjustQty(1)" class="flex h-12 w-12 items-center justify-center rounded-lg border border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50 active:scale-95">
<i data-lucide="plus" class="h-5 w-5"></i>
</button>
</div>
</div>
<div>
<label class="block text-sm font-bold text-neutral-700">Manual Serial Entry (Optional)</label>
<input type="text" id="serial-number"
class="mt-2 block w-full rounded-lg border-neutral-200 py-3 focus:border-verde-500 focus:ring-verde-500 font-mono"
placeholder="Enter Serial from Sticker">
<p class="mt-1 text-[10px] text-neutral-400">Leave blank to use the next available code.</p>
</div>
</div>
<div class="flex flex-col items-end pt-4">
<p class="text-xs font-bold text-neutral-400 uppercase tracking-widest">Total Resident Cost</p>
<p id="total-cost" class="text-3xl font-black text-neutral-900">₱0.00</p>
</div>
<div class="pt-6">
<button type="submit" id="submit-sale" class="flex w-full items-center justify-center gap-3 rounded-xl bg-verde-600 py-4 text-lg font-bold text-white shadow-xl shadow-verde-600/20 transition-all hover:bg-verde-700 hover:shadow-verde-600/30 active:scale-[0.98]">
<i data-lucide="check-circle" class="h-6 w-6"></i>
<span>Confirm & Record Sale</span>
</button>
</div>
</form>
</div>
</div>
{{-- Success Modal --}}
<div id="success-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-neutral-900/60 backdrop-blur-sm p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-8 text-center shadow-2xl">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-verde-50 text-verde-600">
<i data-lucide="check" class="h-10 w-10"></i>
</div>
<h2 class="mt-6 text-2xl font-bold text-neutral-900">Sale Recorded!</h2>
<p class="mt-2 text-neutral-500">The transaction has been successfully logged to the household.</p>
<div id="qr-print-container" class="mt-6 hidden space-y-4">
<div class="mx-auto w-40 h-40 border border-neutral-200 rounded-lg p-2 bg-white">
<img id="printable-qr" src="" class="w-full h-full object-contain">
</div>
<p id="printable-serial" class="font-mono text-sm font-bold text-neutral-600">...</p>
<button onclick="printQRCode()" class="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-neutral-900 py-3 font-bold text-neutral-900 hover:bg-neutral-50">
<i data-lucide="printer" class="h-5 w-5"></i>
Print Sticker
</button>
</div>
<button onclick="resetPage()" class="mt-8 w-full rounded-xl bg-neutral-900 py-3 font-bold text-white transition-transform hover:scale-105">Record Another</button>
</div>
</div>
<script type="module">
const SRP_CENTAVOS = {{ config('qr.default_retail_price_per_code_centavos', 1000) }};
const searchInput = document.getElementById('household-search');
const resultsContainer = document.getElementById('search-results');
const resultsList = document.getElementById('results-list');
const selectedSection = document.getElementById('selected-household-card');
const qtyInput = document.getElementById('sale-qty');
const costDisplay = document.getElementById('total-cost');
window.adjustQty = (amount) => {
const val = parseInt(qtyInput.value) + amount;
if (val >= 1) {
qtyInput.value = val;
updateCost();
}
};
const updateCost = () => {
const qty = parseInt(qtyInput.value) || 0;
const total = (qty * SRP_CENTAVOS) / 100;
costDisplay.textContent = new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(total);
};
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const q = e.target.value;
if (q.length < 2) {
resultsContainer.classList.add('hidden');
return;
}
searchTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/v1/store/households?q=${encodeURIComponent(q)}`, {
headers: { 'Authorization': `Bearer ${window.Verde.getToken()}` }
});
const { data: households } = await res.json();
if (households.length === 0) {
resultsList.innerHTML = '<div class="p-6 text-center text-neutral-400">No matching households found.</div>';
} else {
resultsList.innerHTML = households.map(h => `
<button type="button" class="flex w-full items-center gap-4 px-6 py-4 text-left transition-colors hover:bg-neutral-50" onclick="selectHousehold(${JSON.stringify(h).replace(/"/g, '&quot;')})">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500">
<i data-lucide="home" class="h-5 w-5"></i>
</div>
<div>
<div class="font-bold text-neutral-900">${h.head?.full_name || 'N/A'}</div>
<div class="text-xs text-neutral-500">${h.address_line}</div>
</div>
</button>
`).join('');
}
resultsContainer.classList.remove('hidden');
if (window.lucide) window.lucide.createIcons();
} catch (err) {
console.error('Search failed', err);
}
}, 300);
});
window.selectHousehold = (h) => {
searchInput.value = '';
resultsContainer.classList.add('hidden');
document.getElementById('sel-household-id').value = h.id;
document.getElementById('sel-head-name').textContent = h.head?.full_name || 'N/A';
document.getElementById('sel-address').textContent = h.address_line;
document.getElementById('sel-barangay').textContent = h.barangay?.name || 'Unknown Barangay';
document.getElementById('sel-size').textContent = h.members_count || 0;
selectedSection.classList.remove('hidden');
searchInput.closest('.rounded-xl').classList.add('hidden');
updateCost();
if (window.lucide) window.lucide.createIcons();
};
document.getElementById('cancel-selection').addEventListener('click', () => {
selectedSection.classList.add('hidden');
searchInput.closest('.rounded-xl').classList.remove('hidden');
});
qtyInput.addEventListener('input', updateCost);
document.getElementById('sale-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submit-sale');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i data-lucide="loader-2" class="h-6 w-6 animate-spin"></i> Processing...';
if (window.lucide) window.lucide.createIcons();
try {
const res = await fetch('/api/v1/store/sales', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.Verde.getToken()}`,
'Accept': 'application/json'
},
body: JSON.stringify({
household_id: document.getElementById('sel-household-id').value,
quantity: qtyInput.value,
serial_number: document.getElementById('serial-number').value || null
})
});
if (res.ok) {
const result = await res.json();
if (result.data.codes && result.data.codes.length > 0) {
const firstCode = result.data.codes[0];
document.getElementById('printable-qr').src = `data:image/png;base64,${firstCode.qr_base64}`;
document.getElementById('printable-serial').textContent = firstCode.serial;
document.getElementById('qr-print-container').classList.remove('hidden');
}
document.getElementById('success-modal').classList.replace('hidden', 'flex');
} else {
const err = await res.json();
alert(err.message || 'Failed to record sale.');
}
} catch (err) {
console.error('Sale failed', err);
alert('An unexpected error occurred.');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i data-lucide="check-circle" class="h-6 w-6"></i> Confirm & Record Sale';
if (window.lucide) window.lucide.createIcons();
}
});
window.printQRCode = () => {
const qrContainer = document.getElementById('qr-print-container');
const printWindow = window.open('', '_blank');
const imgHtml = document.getElementById('printable-qr').outerHTML;
const serial = document.getElementById('printable-serial').textContent;
printWindow.document.write(`
<html>
<head>
<title>Print QR Sticker</title>
<style>
body { margin: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; font-family: monospace; }
img { width: 150px; height: 150px; }
.serial { margin-top: 5px; font-weight: bold; font-size: 14px; }
@page { size: auto; margin: 0; }
</style>
</head>
<body onload="window.print(); window.close();">
${imgHtml}
<div class="serial">${serial}</div>
</body>
</html>
`);
printWindow.document.close();
};
window.resetPage = () => {
location.reload();
};
// Close results when clicking outside
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !resultsContainer.contains(e.target)) {
resultsContainer.classList.add('hidden');
}
});
</script>
@endsection

View File

@@ -49,6 +49,7 @@ use App\Http\Controllers\Api\V1\Payment\PaymentController;
use App\Http\Controllers\Api\V1\Qr\MyQrCodeController;
use App\Http\Controllers\Api\V1\Scanner\ScannerController;
use App\Http\Controllers\Api\V1\Store\PartnerStorePublicController;
use App\Http\Controllers\Api\V1\Store\StorePortalController;
use App\Http\Controllers\Api\V1\SuperAdmin\SuperAdminBarangayController;
use App\Http\Controllers\Api\V1\SuperAdmin\SuperAdminTenantController;
use App\Http\Controllers\Api\V1\SuperAdmin\SuperAdminUserController;
@@ -135,6 +136,16 @@ Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api.
Route::post('/trips/{trip}/resume', [\App\Http\Controllers\Api\V1\Driver\TripDetourController::class, 'resume'])->name('trips.detour.resume');
});
// 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');
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');
Route::get('/inventory', [StorePortalController::class, 'inventoryHistory'])->name('inventory.index');
Route::get('/households', [StorePortalController::class, 'searchHouseholds'])->name('households.search');
});
// Admin live tracking
Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/live')->name('api.v1.admin.live.')->group(function () {
Route::get('/trucks', [AdminLiveTrackingController::class, 'trucks'])->name('trucks');

View File

@@ -42,3 +42,10 @@ Route::prefix('admin')->group(function () {
'description' => 'Filtered view of trip incident events — UI work pending. Visible inline in each Trip detail.',
]);
});
Route::prefix('store')->name('store.')->group(function () {
Route::get('/dashboard', [\App\Http\Controllers\Store\StoreDashboardController::class, 'index'])->name('dashboard');
Route::get('/sales', [\App\Http\Controllers\Store\StoreDashboardController::class, 'sales'])->name('sales');
Route::get('/inventory', [\App\Http\Controllers\Store\StoreDashboardController::class, 'inventory'])->name('inventory');
Route::get('/financials', [\App\Http\Controllers\Store\StoreDashboardController::class, 'financials'])->name('financials');
});